Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
class CounterComponent < Azu::Component
def initialize(@initial_count : Int32 = 0)
@count = @initial_count
end
def content
div class: "counter", id: "counter-#{object_id}" do
h3 "Counter"
div class: "display" do
span id: "count", class: "count-value" do
text @count.to_s
end
end
div class: "controls" do
button onclick: "increment()", class: "btn btn-primary" do
text "+"
end
button onclick: "decrement()", class: "btn btn-secondary" do
text "-"
end
button onclick: "reset()", class: "btn btn-danger" do
text "Reset"
end
end
end
end
def on_event("increment", data)
@count += 1
update_element "count", @count.to_s
end
def on_event("decrement", data)
@count -= 1
update_element "count", @count.to_s
end
def on_event("reset", data)
@count = @initial_count
update_element "count", @count.to_s
end
endstruct CounterEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Html)
include Azu::Templates::Renderable
get "/counter"
def call
component = CounterComponent.new(0)
html <<-HTML
<!DOCTYPE html>
<html>
<head>
<title>Live Counter</title>
<style>
.counter { text-align: center; padding: 20px; }
.count-value { font-size: 48px; font-weight: bold; }
.controls { margin-top: 20px; }
.btn { padding: 10px 20px; margin: 5px; cursor: pointer; }
.btn-primary { background: #007bff; color: white; border: none; }
.btn-secondary { background: #6c757d; color: white; border: none; }
.btn-danger { background: #dc3545; color: white; border: none; }
</style>
</head>
<body>
#{component.render}
<script>
const ws = new WebSocket('ws://localhost:4000/spark');
function increment() {
ws.send(JSON.stringify({type: 'event', event: 'increment'}));
}
function decrement() {
ws.send(JSON.stringify({type: 'event', event: 'decrement'}));
}
function reset() {
ws.send(JSON.stringify({type: 'event', event: 'reset'}));
}
ws.onmessage = function(event) {
const data = JSON.parse(event.data);
if (data.type === 'update') {
document.getElementById(data.id).innerHTML = data.content;
}
};
</script>
</body>
</html>
HTML
end
endclass ChatComponent < Azu::Component
def initialize(@room_id : String)
@messages = [] of NamedTuple(user: String, text: String, time: Time)
@users = Set(String).new
end
def content
div class: "chat-room", id: "chat-#{object_id}" do
div class: "chat-header" do
h3 "Room: #{@room_id}"
span id: "user-count" do
text "#{@users.size} users"
end
end
div id: "messages", class: "messages" do
@messages.each do |msg|
render_message(msg)
end
end
form onsubmit: "sendMessage(event)", class: "message-form" do
input type: "text", id: "message-input", placeholder: "Type a message..."
button type: "submit" do
text "Send"
end
end
end
end
def on_event("send_message", data)
message = data["text"]?.try(&.as_s)
user = data["user"]?.try(&.as_s) || "Anonymous"
return unless message && !message.strip.empty?
msg = {user: user, text: message, time: Time.utc}
@messages << msg
# Append new message to DOM
append_element "messages", render_message_html(msg)
end
def on_event("user_joined", data)
user = data["user"]?.try(&.as_s)
return unless user
@users << user
update_element "user-count", "#{@users.size} users"
# Add system message
append_element "messages", <<-HTML
<div class="system-message">#{user} joined the room</div>
HTML
end
def on_event("user_left", data)
user = data["user"]?.try(&.as_s)
return unless user
@users.delete(user)
update_element "user-count", "#{@users.size} users"
append_element "messages", <<-HTML
<div class="system-message">#{user} left the room</div>
HTML
end
private def render_message(msg)
div class: "message" do
span class: "user" do
text msg[:user]
end
span class: "text" do
text msg[:text]
end
time class: "timestamp" do
text msg[:time].to_s("%H:%M")
end
end
end
private def render_message_html(msg)
<<-HTML
<div class="message">
<span class="user">#{msg[:user]}</span>
<span class="text">#{msg[:text]}</span>
<time class="timestamp">#{msg[:time].to_s("%H:%M")}</time>
</div>
HTML
end
endclass LifecycleComponent < Azu::Component
def on_mount
# Called when component is first mounted
# Load initial data, set up subscriptions
Log.info { "Component mounted" }
end
def on_unmount
# Called when component is removed
# Clean up resources, unsubscribe
Log.info { "Component unmounted" }
end
def on_connect
# Called when WebSocket connects
Log.info { "WebSocket connected" }
end
def on_disconnect
# Called when WebSocket disconnects
Log.info { "WebSocket disconnected" }
end
endclass StatefulComponent < Azu::Component
def initialize
@state = {} of String => JSON::Any
@listeners = [] of Proc(Nil)
end
# Get state value
def get(key : String)
@state[key]?
end
# Set state and notify listeners
def set(key : String, value)
@state[key] = JSON::Any.new(value)
notify_listeners
render_state
end
# Subscribe to state changes
def subscribe(&block : -> Nil)
@listeners << block
end
def on_event("update_state", data)
key = data["key"]?.try(&.as_s)
value = data["value"]?
return unless key && value
set(key, value)
end
private def notify_listeners
@listeners.each(&.call)
end
private def render_state
content = @state.map do |k, v|
"<div><strong>#{k}:</strong> #{v}</div>"
end.join
update_element "state-display", content
end
endclass FormComponent < Azu::Component
def initialize
@errors = {} of String => String
@values = {} of String => String
end
def content
form onsubmit: "submitForm(event)", id: "user-form" do
div class: "form-group" do
label "Name", for: "name"
input type: "text", id: "name", name: "name", value: @values["name"]?
if error = @errors["name"]?
span class: "error" do
text error
end
end
end
div class: "form-group" do
label "Email", for: "email"
input type: "email", id: "email", name: "email", value: @values["email"]?
if error = @errors["email"]?
span class: "error" do
text error
end
end
end
button type: "submit" do
text "Submit"
end
end
end
def on_event("submit_form", data)
@values = data.as_h.transform_values(&.as_s)
@errors.clear
validate_form
if @errors.empty?
process_form
else
render_errors
end
end
private def validate_form
name = @values["name"]?
email = @values["email"]?
@errors["name"] = "Name is required" if name.nil? || name.strip.empty?
@errors["email"] = "Email is required" if email.nil? || email.strip.empty?
@errors["email"] = "Invalid email" if email && !email.includes?("@")
end
private def render_errors
@errors.each do |field, message|
# Show error next to field
update_element "#{field}-error", message
end
end
private def process_form
# Handle successful submission
update_element "user-form", <<-HTML
<div class="success">Form submitted successfully!</div>
HTML
end
endclass ButtonComponent < Azu::Component
def initialize(@text : String, @variant : String = "primary")
end
def content
button class: "btn btn-#{@variant}" do
text @text
end
end
end
class CardComponent < Azu::Component
def initialize(@title : String, &@block : -> Nil)
end
def content
div class: "card" do
div class: "card-header" do
h4 @title
end
div class: "card-body" do
@block.call
end
end
end
end
class UserCardComponent < Azu::Component
def initialize(@user : User)
end
def content
CardComponent.new(@user.name) do
p @user.email
p "Joined: #{@user.created_at.to_s("%B %Y")}"
div class: "actions" do
ButtonComponent.new("Edit", "primary").render
ButtonComponent.new("Delete", "danger").render
end
end.render
end
endclass MyComponent < Azu::Component
def content # Define the HTML structure
def on_event(...) # Handle events from client
def on_mount # Called when mounted
def on_unmount # Called when removed
endupdate_element "id", "new content" # Replace element content
append_element "id", "html" # Append to element
remove_element "id" # Remove elementdef on_event("event_name", data)
# data is a JSON::Any with event payload
value = data["key"]?.try(&.as_s)
endcrystal init app user_api
cd user_apiname: user_api
version: 0.1.0
dependencies:
azu:
github: azutoolkit/azu
version: ~> 0.5.28
crystal: >= 0.35.0
license: MITshards installmkdir -p src/{models,requests,responses,endpoints}class User
property id : Int64
property name : String
property email : String
property age : Int32?
property created_at : Time
property updated_at : Time
@@next_id = 1_i64
@@users = [] of User
def initialize(@name : String, @email : String, @age : Int32? = nil)
@id = @@next_id
@@next_id += 1
@created_at = Time.utc
@updated_at = Time.utc
@@users << self
end
def self.all : Array(User)
@@users.dup
end
def self.find(id : Int64) : User?
@@users.find { |u| u.id == id }
end
def self.find_by_email(email : String) : User?
@@users.find { |u| u.email == email }
end
def update(name : String? = nil, email : String? = nil, age : Int32? = nil)
@name = name if name
@email = email if email
@age = age if age
@updated_at = Time.utc
end
def delete
@@users.delete(self)
end
def to_json(json : JSON::Builder)
json.object do
json.field "id", @id
json.field "name", @name
json.field "email", @email
json.field "age", @age
json.field "created_at", @created_at.to_rfc3339
json.field "updated_at", @updated_at.to_rfc3339
end
end
endstruct CreateUserRequest
include Azu::Request
getter name : String
getter email : String
getter age : Int32?
def initialize(@name = "", @email = "", @age = nil)
end
validate name, presence: true, length: {min: 2, max: 50},
message: "Name must be between 2 and 50 characters"
validate email, presence: true, format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i,
message: "Email must be a valid email address"
validate age, numericality: {greater_than: 0, less_than: 150}, allow_nil: true,
message: "Age must be between 1 and 150"
endstruct UpdateUserRequest
include Azu::Request
getter name : String?
getter email : String?
getter age : Int32?
def initialize(@name = nil, @email = nil, @age = nil)
end
validate name, length: {min: 2, max: 50}, allow_nil: true,
message: "Name must be between 2 and 50 characters"
validate email, format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i, allow_nil: true,
message: "Email must be a valid email address"
validate age, numericality: {greater_than: 0, less_than: 150}, allow_nil: true,
message: "Age must be between 1 and 150"
endstruct UserResponse
include Azu::Response
def initialize(@user : User)
end
def render
{
id: @user.id,
name: @user.name,
email: @user.email,
age: @user.age,
created_at: @user.created_at.to_rfc3339,
updated_at: @user.updated_at.to_rfc3339
}.to_json
end
endstruct UsersListResponse
include Azu::Response
def initialize(@users : Array(User))
end
def render
{
users: @users.map { |user| user_json(user) },
count: @users.size,
timestamp: Time.utc.to_rfc3339
}.to_json
end
private def user_json(user : User)
{
id: user.id,
name: user.name,
email: user.email,
age: user.age,
created_at: user.created_at.to_rfc3339
}
end
endstruct CreateUserEndpoint
include Azu::Endpoint(CreateUserRequest, UserResponse)
post "/users"
def call : UserResponse
# Validate request
unless create_user_request.valid?
raise Azu::Response::ValidationError.new(
create_user_request.errors.group_by(&.field).transform_values(&.map(&.message))
)
end
# Check for duplicate email
if User.find_by_email(create_user_request.email)
raise Azu::Response::ValidationError.new(
{"email" => ["Email is already taken"]}
)
end
# Create user
user = User.new(
name: create_user_request.name,
email: create_user_request.email,
age: create_user_request.age
)
status 201
UserResponse.new(user)
end
endstruct ListUsersEndpoint
include Azu::Endpoint(EmptyRequest, UsersListResponse)
get "/users"
def call : UsersListResponse
users = User.all
UsersListResponse.new(users)
end
endstruct ShowUserEndpoint
include Azu::Endpoint(EmptyRequest, UserResponse)
get "/users/:id"
def call : UserResponse
user_id = params["id"].to_i64
if user = User.find(user_id)
UserResponse.new(user)
else
raise Azu::Response::NotFound.new("/users/#{user_id}")
end
end
endstruct UpdateUserEndpoint
include Azu::Endpoint(UpdateUserRequest, UserResponse)
put "/users/:id"
def call : UserResponse
user_id = params["id"].to_i64
unless user = User.find(user_id)
raise Azu::Response::NotFound.new("/users/#{user_id}")
end
# Validate request
unless update_user_request.valid?
raise Azu::Response::ValidationError.new(
update_user_request.errors.group_by(&.field).transform_values(&.map(&.message))
)
end
# Check for duplicate email if updating
if email = update_user_request.email
if existing = User.find_by_email(email)
unless existing.id == user_id
raise Azu::Response::ValidationError.new(
{"email" => ["Email is already taken"]}
)
end
end
end
# Update user
user.update(
name: update_user_request.name,
email: update_user_request.email,
age: update_user_request.age
)
UserResponse.new(user)
end
endstruct DeleteUserEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Empty)
delete "/users/:id"
def call : Azu::Response::Empty
user_id = params["id"].to_i64
unless user = User.find(user_id)
raise Azu::Response::NotFound.new("/users/#{user_id}")
end
user.delete
status 204
Azu::Response::Empty.new
end
endrequire "azu"
# Load application files
require "./models/*"
require "./requests/*"
require "./responses/*"
require "./endpoints/*"
module UserAPI
include Azu
configure do
port = ENV.fetch("PORT", "4000").to_i
host = ENV.fetch("HOST", "0.0.0.0")
log.level = Log::Severity::DEBUG
end
end
# Start the application
UserAPI.start [
Azu::Handler::RequestId.new,
Azu::Handler::Rescuer.new,
Azu::Handler::Logger.new,
Azu::Handler::CORS.new,
ListUsersEndpoint.new,
ShowUserEndpoint.new,
CreateUserEndpoint.new,
UpdateUserEndpoint.new,
DeleteUserEndpoint.new,
]crystal run src/user_api.crcurl -X POST http://localhost:4000/users \
-H "Content-Type: application/json" \
-d '{"name": "Alice Smith", "email": "alice@example.com", "age": 30}'{
"id": 1,
"name": "Alice Smith",
"email": "alice@example.com",
"age": 30,
"created_at": "2026-01-24T15:30:45Z",
"updated_at": "2026-01-24T15:30:45Z"
}curl http://localhost:4000/userscurl http://localhost:4000/users/1curl -X PUT http://localhost:4000/users/1 \
-H "Content-Type: application/json" \
-d '{"name": "Alice Johnson", "age": 31}'curl -X DELETE http://localhost:4000/users/1curl -X POST http://localhost:4000/users \
-H "Content-Type: application/json" \
-d '{}'{
"Status": "Unprocessable Entity",
"Title": "Validation Error",
"FieldErrors": {
"name": ["Name must be between 2 and 50 characters"],
"email": ["Email must be a valid email address"]
}
}include Azu::Endpoint(CreateUserRequest, UserResponse)validate name, presence: true, length: {min: 2, max: 50}get "/users/:id"
# params["id"] contains the valueuser_api/
βββ shard.yml
βββ src/
β βββ user_api.cr # Main application
β βββ models/
β β βββ user.cr # User model
β βββ requests/
β β βββ create_user_request.cr
β β βββ update_user_request.cr
β βββ responses/
β β βββ user_response.cr
β β βββ users_list_response.cr
β βββ endpoints/
β βββ create_user_endpoint.cr
β βββ list_users_endpoint.cr
β βββ show_user_endpoint.cr
β βββ update_user_endpoint.cr
β βββ delete_user_endpoint.cr
βββ spec/name: user_api
version: 0.1.0
dependencies:
azu:
github: azutoolkit/azu
version: ~> 0.5.28
cql:
github: azutoolkit/cql
version: ~> 0.1.0
# Choose your database driver:
pg: # PostgreSQL
github: will/crystal-pg
# OR
# sqlite3: # SQLite (development)
# github: crystal-lang/crystal-sqlite3
crystal: >= 0.35.0
license: MITshards installrequire "cql"
AppDB = CQL::Schema.define(
:app_database,
adapter: CQL::Adapter::Postgres,
uri: ENV.fetch("DATABASE_URL", "postgres://localhost:5432/user_api_dev")
) do
table :users do
primary :id, Int64
text :name
text :email
integer :age, null: true
boolean :active, default: "true"
timestamps
index :email, unique: true
end
endAppDB = CQL::Schema.define(
:app_database,
adapter: CQL::Adapter::SQLite,
uri: "sqlite3://./db/development.db"
) do
# Same table definitions...
endcreatedb user_api_devmkdir -p db
touch db/development.dbstruct User
include CQL::ActiveRecord::Model(Int64)
db_context AppDB, :users
getter id : Int64?
getter name : String
getter email : String
getter age : Int32?
getter active : Bool
getter created_at : Time
getter updated_at : Time
# Validations
validates :name, presence: true, size: 2..50
validates :email, presence: true
# Scopes for common queries
scope :active, -> { where(active: true) }
scope :recent, -> { order(created_at: :desc) }
# Instance methods
def activate!
@active = true
save!
end
def deactivate!
@active = false
save!
end
endstruct CreateUserEndpoint
include Azu::Endpoint(CreateUserRequest, UserResponse)
post "/users"
def call : UserResponse
# Validate request
unless create_user_request.valid?
raise Azu::Response::ValidationError.new(
create_user_request.errors.group_by(&.field).transform_values(&.map(&.message))
)
end
# Check for duplicate email
if User.find_by(email: create_user_request.email)
raise Azu::Response::ValidationError.new(
{"email" => ["Email is already taken"]}
)
end
# Create user in database
user = User.create!(
name: create_user_request.name,
email: create_user_request.email,
age: create_user_request.age
)
status 201
UserResponse.new(user)
rescue CQL::RecordInvalid => e
raise Azu::Response::ValidationError.new(e.errors)
end
endstruct ListUsersEndpoint
include Azu::Endpoint(EmptyRequest, UsersListResponse)
get "/users"
def call : UsersListResponse
# Use scopes for filtering
users = User.active.recent.limit(100).all
UsersListResponse.new(users)
end
endstruct ShowUserEndpoint
include Azu::Endpoint(EmptyRequest, UserResponse)
get "/users/:id"
def call : UserResponse
user_id = params["id"].to_i64
if user = User.find?(user_id)
UserResponse.new(user)
else
raise Azu::Response::NotFound.new("/users/#{user_id}")
end
end
endstruct UpdateUserEndpoint
include Azu::Endpoint(UpdateUserRequest, UserResponse)
put "/users/:id"
def call : UserResponse
user_id = params["id"].to_i64
unless user = User.find?(user_id)
raise Azu::Response::NotFound.new("/users/#{user_id}")
end
unless update_user_request.valid?
raise Azu::Response::ValidationError.new(
update_user_request.errors.group_by(&.field).transform_values(&.map(&.message))
)
end
# Check duplicate email
if email = update_user_request.email
if existing = User.find_by(email: email)
unless existing.id == user_id
raise Azu::Response::ValidationError.new(
{"email" => ["Email is already taken"]}
)
end
end
end
# Update the user
user.update!(
name: update_user_request.name,
email: update_user_request.email,
age: update_user_request.age
)
UserResponse.new(user)
rescue CQL::RecordInvalid => e
raise Azu::Response::ValidationError.new(e.errors)
end
endstruct DeleteUserEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Empty)
delete "/users/:id"
def call : Azu::Response::Empty
user_id = params["id"].to_i64
unless user = User.find?(user_id)
raise Azu::Response::NotFound.new("/users/#{user_id}")
end
user.destroy!
status 204
Azu::Response::Empty.new
end
endrequire "azu"
# Load database configuration first
require "./config/database"
# Load application files
require "./models/*"
require "./requests/*"
require "./responses/*"
require "./endpoints/*"
module UserAPI
include Azu
configure do
port = ENV.fetch("PORT", "4000").to_i
host = ENV.fetch("HOST", "0.0.0.0")
end
end
# Create tables if they don't exist
AppDB.create_tables
# Start the application
UserAPI.start [
Azu::Handler::RequestId.new,
Azu::Handler::Rescuer.new,
Azu::Handler::Logger.new,
Azu::Handler::CORS.new,
ListUsersEndpoint.new,
ShowUserEndpoint.new,
CreateUserEndpoint.new,
UpdateUserEndpoint.new,
DeleteUserEndpoint.new,
]AppDB = CQL::Schema.define(
:app_database,
adapter: CQL::Adapter::Postgres,
uri: ENV["DATABASE_URL"]
) do
table :users do
primary :id, Int64
text :name
text :email
integer :age, null: true
boolean :active, default: "true"
timestamps
index :email, unique: true
end
table :posts do
primary :id, Int64
text :title
text :content
bigint :user_id
boolean :published, default: "false"
timestamps
index :user_id
end
endstruct Post
include CQL::ActiveRecord::Model(Int64)
db_context AppDB, :posts
getter id : Int64?
getter title : String
getter content : String
getter user_id : Int64
getter published : Bool
getter created_at : Time
getter updated_at : Time
belongs_to :user, User, foreign_key: :user_id
validates :title, presence: true, size: 5..200
validates :content, presence: true
validates :user_id, presence: true
scope :published, -> { where(published: true) }
scope :drafts, -> { where(published: false) }
scope :by_user, ->(id : Int64) { where(user_id: id) }
def publish!
@published = true
save!
end
endstruct User
include CQL::ActiveRecord::Model(Int64)
db_context AppDB, :users
# ... existing fields ...
has_many :posts, Post, foreign_key: :user_id
end# Find by ID
user = User.find(1) # Raises if not found
user = User.find?(1) # Returns nil if not found
# Find by attributes
user = User.find_by(email: "alice@example.com")
# Queries with conditions
active_users = User.where(active: true).all
recent_users = User.order(created_at: :desc).limit(10).all
# Using scopes
User.active.recent.limit(20).all
# Relationships
user = User.find(1)
user_posts = user.posts.all
published_posts = user.posts.published.all
# Create with relationship
post = Post.create!(
title: "My First Post",
content: "Hello, world!",
user_id: user.id
)
# Count records
User.count
User.where(active: true).countDATABASE_URL=postgres://localhost:5432/user_api_dev
PORT=4000DATABASE_URL=postgres://user:password@db.example.com:5432/user_api_prod
PORT=8080CQL::Schema.define(:name, adapter: Adapter, uri: "...") do
table :name do
primary :id, Int64
text :field
timestamps
index :field, unique: true
end
endstruct Model
include CQL::ActiveRecord::Model(Int64)
db_context Schema, :table
getter field : Type
validates :field, presence: true
scope :name, -> { where(...) }
has_many :relation, OtherModel
endModel.create!(attrs) # Create
Model.find?(id) # Read
model.update!(attrs) # Update
model.destroy! # Deleteget "/users/:id"
def call
id = params["id"] # String
id.to_i64 # Convert to Int64
endget "/users/:user_id/posts/:post_id"
def call
user_id = params["user_id"].to_i64
post_id = params["post_id"].to_i64
end# URL: /search?q=crystal&page=2
get "/search"
def call
query = params["q"]? # "crystal" or nil
page = params["page"]? || "1" # "2" or default "1"
endstruct CreateUserRequest
include Azu::Request
getter name : String
getter email : String
getter age : Int32?
def initialize(@name = "", @email = "", @age = nil)
end
end
struct CreateUserEndpoint
include Azu::Endpoint(CreateUserRequest, UserResponse)
post "/users"
def call
name = create_user_request.name
email = create_user_request.email
age = create_user_request.age
end
endstruct FormEndpoint
include Azu::Endpoint(FormRequest, FormResponse)
post "/submit"
def call
# Access form fields from request contract
name = form_request.name
email = form_request.email
end
endstruct UploadRequest
include Azu::Request
getter file : HTTP::FormData::File
getter description : String?
def initialize(@file, @description = nil)
end
end
struct UploadEndpoint
include Azu::Endpoint(UploadRequest, UploadResponse)
post "/upload"
def call
file = upload_request.file
filename = file.filename
content = file.content
content_type = file.content_type
end
enddef call
auth = headers["Authorization"]?
user_agent = headers["User-Agent"]?
accept = headers["Accept"]?
enddef call
# String to integer
id = params["id"].to_i64
# String to boolean
active = params["active"]? == "true"
# String to enum
status = Status.parse(params["status"]? || "pending")
enddef call
page = (params["page"]? || "1").to_i
per_page = (params["per_page"]? || "20").to_i
sort = params["sort"]? || "created_at"
order = params["order"]? || "desc"
endstruct HelloEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Text)
get "/"
def call
text "Hello, World!"
end
endstruct UserEndpoint
include Azu::Endpoint(EmptyRequest, UserResponse)
get "/users/:id"
def call : UserResponse
user_id = params["id"].to_i64
user = User.find(user_id)
if user
UserResponse.new(user)
else
raise Azu::Response::NotFound.new("/users/#{user_id}")
end
end
end
struct UserResponse
include Azu::Response
def initialize(@user : User)
end
def render
{id: @user.id, name: @user.name, email: @user.email}.to_json
end
endget "/path" # GET request
post "/path" # POST request
put "/path" # PUT request
patch "/path" # PATCH request
delete "/path" # DELETE requestget "/users/:id/posts/:post_id"
def call
user_id = params["id"]
post_id = params["post_id"]
endget "/search"
def call
query = params["q"]? # Optional
page = params["page"]? || "1"
enddef call
auth_header = headers["Authorization"]?
content_type = headers["Content-Type"]?
enddef call
status 201 # Created
UserResponse.new(user)
endMyApp.start [
Azu::Handler::Rescuer.new,
Azu::Handler::Logger.new,
HelloEndpoint.new,
UserEndpoint.new,
]require "spec"
require "../src/user_api"
# Test configuration
module TestConfig
def self.setup
# Use test database
ENV["DATABASE_URL"] = "sqlite3://./test.db"
ENV["AZU_ENV"] = "test"
end
end
# Helper module for creating test contexts
module TestHelpers
def create_context(
method : String = "GET",
path : String = "/",
body : String? = nil,
headers : HTTP::Headers = HTTP::Headers.new
) : HTTP::Server::Context
io = IO::Memory.new
request = HTTP::Request.new(method, path, headers, body)
response = HTTP::Server::Response.new(io)
HTTP::Server::Context.new(request, response)
end
def json_headers : HTTP::Headers
headers = HTTP::Headers.new
headers["Content-Type"] = "application/json"
headers
end
def parse_response(context : HTTP::Server::Context) : JSON::Any
context.response.close
body = context.response.@io.as(IO::Memory).to_s
JSON.parse(body.split("\r\n\r\n").last)
end
end
# Setup before all tests
TestConfig.setup
Spec.before_each do
# Clean database before each test
User.delete_all if defined?(User)
endrequire "../spec_helper"
describe CreateUserEndpoint do
include TestHelpers
describe "#call" do
it "creates a user with valid data" do
body = {
name: "Alice Smith",
email: "alice@example.com",
age: 30
}.to_json
context = create_context("POST", "/users", body, json_headers)
endpoint = CreateUserEndpoint.new
# Simulate the request
endpoint.context = context
response = endpoint.call
response.should be_a(UserResponse)
context.response.status_code.should eq(201)
end
it "returns validation error for missing name" do
body = {email: "alice@example.com"}.to_json
context = create_context("POST", "/users", body, json_headers)
endpoint = CreateUserEndpoint.new
endpoint.context = context
expect_raises(Azu::Response::ValidationError) do
endpoint.call
end
end
it "returns validation error for invalid email" do
body = {name: "Alice", email: "invalid-email"}.to_json
context = create_context("POST", "/users", body, json_headers)
endpoint = CreateUserEndpoint.new
endpoint.context = context
expect_raises(Azu::Response::ValidationError) do
endpoint.call
end
end
it "returns validation error for duplicate email" do
# Create first user
User.create!(name: "First", email: "alice@example.com")
body = {name: "Second", email: "alice@example.com"}.to_json
context = create_context("POST", "/users", body, json_headers)
endpoint = CreateUserEndpoint.new
endpoint.context = context
expect_raises(Azu::Response::ValidationError) do
endpoint.call
end
end
end
endrequire "../spec_helper"
describe ShowUserEndpoint do
include TestHelpers
describe "#call" do
it "returns user when found" do
user = User.create!(name: "Alice", email: "alice@example.com")
context = create_context("GET", "/users/#{user.id}")
endpoint = ShowUserEndpoint.new
endpoint.context = context
endpoint.params = {"id" => user.id.to_s}
response = endpoint.call
response.should be_a(UserResponse)
end
it "raises NotFound for non-existent user" do
context = create_context("GET", "/users/999")
endpoint = ShowUserEndpoint.new
endpoint.context = context
endpoint.params = {"id" => "999"}
expect_raises(Azu::Response::NotFound) do
endpoint.call
end
end
end
endrequire "../spec_helper"
describe CreateUserRequest do
describe "validation" do
it "validates with correct data" do
request = CreateUserRequest.new(
name: "Alice Smith",
email: "alice@example.com",
age: 30
)
request.valid?.should be_true
request.errors.should be_empty
end
it "requires name" do
request = CreateUserRequest.new(
name: "",
email: "alice@example.com"
)
request.valid?.should be_false
request.errors.map(&.field).should contain("name")
end
it "validates name length" do
request = CreateUserRequest.new(
name: "A", # Too short
email: "alice@example.com"
)
request.valid?.should be_false
end
it "requires email" do
request = CreateUserRequest.new(
name: "Alice Smith",
email: ""
)
request.valid?.should be_false
request.errors.map(&.field).should contain("email")
end
it "validates email format" do
request = CreateUserRequest.new(
name: "Alice Smith",
email: "invalid-email"
)
request.valid?.should be_false
end
it "allows nil age" do
request = CreateUserRequest.new(
name: "Alice Smith",
email: "alice@example.com",
age: nil
)
request.valid?.should be_true
end
it "validates age range" do
request = CreateUserRequest.new(
name: "Alice Smith",
email: "alice@example.com",
age: 200 # Too old
)
request.valid?.should be_false
end
end
endrequire "../spec_helper"
describe User do
describe "validations" do
it "requires name" do
user = User.new(email: "test@example.com")
user.valid?.should be_false
user.errors.should contain("name")
end
it "validates name length" do
user = User.new(name: "A", email: "test@example.com")
user.valid?.should be_false
end
it "requires email" do
user = User.new(name: "Test User")
user.valid?.should be_false
end
end
describe "CRUD operations" do
it "creates a user" do
user = User.create!(name: "Test", email: "test@example.com")
user.id.should_not be_nil
user.name.should eq("Test")
user.created_at.should_not be_nil
end
it "finds a user by ID" do
created = User.create!(name: "Test", email: "test@example.com")
found = User.find?(created.id.not_nil!)
found.should_not be_nil
found.try(&.name).should eq("Test")
end
it "updates a user" do
user = User.create!(name: "Test", email: "test@example.com")
user.update!(name: "Updated")
User.find?(user.id.not_nil!).try(&.name).should eq("Updated")
end
it "deletes a user" do
user = User.create!(name: "Test", email: "test@example.com")
user.destroy!
User.find?(user.id.not_nil!).should be_nil
end
end
describe "scopes" do
it "filters active users" do
User.create!(name: "Active", email: "active@example.com", active: true)
User.create!(name: "Inactive", email: "inactive@example.com", active: false)
active_users = User.active.all
active_users.size.should eq(1)
active_users.first.name.should eq("Active")
end
it "orders by recent" do
first = User.create!(name: "First", email: "first@example.com")
sleep 0.1.seconds
second = User.create!(name: "Second", email: "second@example.com")
users = User.recent.all
users.first.name.should eq("Second")
end
end
endrequire "../spec_helper"
describe NotificationChannel do
describe "#on_connect" do
it "adds socket to connections" do
channel = NotificationChannel.new
socket = MockWebSocket.new
initial_count = NotificationChannel::CONNECTIONS.size
channel.socket = socket
channel.on_connect
NotificationChannel::CONNECTIONS.size.should eq(initial_count + 1)
end
it "sends welcome message" do
channel = NotificationChannel.new
socket = MockWebSocket.new
channel.socket = socket
channel.on_connect
socket.sent_messages.size.should eq(1)
message = JSON.parse(socket.sent_messages.first)
message["type"].should eq("connected")
end
end
describe "#on_message" do
it "responds to ping with pong" do
channel = NotificationChannel.new
socket = MockWebSocket.new
channel.socket = socket
channel.on_connect
channel.on_message(%({"type": "ping"}))
messages = socket.sent_messages
pong = messages.find { |m| JSON.parse(m)["type"] == "pong" }
pong.should_not be_nil
end
it "handles invalid JSON" do
channel = NotificationChannel.new
socket = MockWebSocket.new
channel.socket = socket
channel.on_connect
channel.on_message("invalid json")
messages = socket.sent_messages
error = messages.find { |m| JSON.parse(m)["type"] == "error" }
error.should_not be_nil
end
end
describe "#on_close" do
it "removes socket from connections" do
channel = NotificationChannel.new
socket = MockWebSocket.new
channel.socket = socket
channel.on_connect
count_before = NotificationChannel::CONNECTIONS.size
channel.on_close(nil, nil)
NotificationChannel::CONNECTIONS.size.should eq(count_before - 1)
end
end
end
# Mock WebSocket for testing
class MockWebSocket
getter sent_messages = [] of String
def send(message : String)
@sent_messages << message
end
def object_id
0_u64
end
endrequire "../spec_helper"
require "http/client"
describe "User API Integration" do
# Start server before tests
before_all do
spawn do
UserAPI.start
end
sleep 1.second # Wait for server to start
end
it "creates and retrieves a user" do
# Create user
response = HTTP::Client.post(
"http://localhost:4000/users",
headers: HTTP::Headers{"Content-Type" => "application/json"},
body: {name: "Integration Test", email: "integration@test.com"}.to_json
)
response.status_code.should eq(201)
user = JSON.parse(response.body)
user["name"].should eq("Integration Test")
# Retrieve user
get_response = HTTP::Client.get("http://localhost:4000/users/#{user["id"]}")
get_response.status_code.should eq(200)
end
it "lists all users" do
response = HTTP::Client.get("http://localhost:4000/users")
response.status_code.should eq(200)
data = JSON.parse(response.body)
data["users"].as_a.should be_a(Array(JSON::Any))
end
it "returns 404 for non-existent user" do
response = HTTP::Client.get("http://localhost:4000/users/999999")
response.status_code.should eq(404)
end
it "validates request data" do
response = HTTP::Client.post(
"http://localhost:4000/users",
headers: HTTP::Headers{"Content-Type" => "application/json"},
body: {name: ""}.to_json
)
response.status_code.should eq(422)
end
endcrystal speccrystal spec spec/endpoints/create_user_endpoint_spec.crcrystal spec --verbosecrystal spec --tag focusspec/
βββ spec_helper.cr # Test configuration
βββ endpoints/ # Endpoint tests
β βββ create_user_endpoint_spec.cr
β βββ show_user_endpoint_spec.cr
β βββ ...
βββ requests/ # Request validation tests
β βββ create_user_request_spec.cr
β βββ ...
βββ models/ # Model tests
β βββ user_spec.cr
βββ channels/ # WebSocket channel tests
β βββ notification_channel_spec.cr
βββ integration/ # Integration tests
βββ api_spec.crdescribe ClassName do
describe "#method" do
it "does something" do
# Arrange
# Act
# Assert
end
end
endvalue.should eq(expected)
value.should be_true
value.should be_nil
value.should_not be_nil
array.should contain(item)
expect_raises(ErrorClass) { code }struct CreateUserRequest
include Azu::Request
getter name : String
getter email : String
getter age : Int32?
def initialize(@name = "", @email = "", @age = nil)
end
validate name, presence: true
validate email, presence: true
endstruct CreateUserEndpoint
include Azu::Endpoint(CreateUserRequest, UserResponse)
post "/users"
def call : UserResponse
# If validation fails, Azu raises ValidationError automatically
# The error handler converts it to a 422 response
UserResponse.new(User.create!(create_user_request))
end
endstruct JsonEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Json)
get "/api/data"
def call
json({
message: "Hello",
timestamp: Time.utc.to_rfc3339
})
end
endvalidate name, presence: truevalidate name, length: {min: 2, max: 100}
validate bio, length: {max: 500}
validate code, length: {is: 6}validate email, format: /@/
validate phone, format: /^\d{10}$/
validate slug, format: /^[a-z0-9-]+$/validate age, numericality: {greater_than: 0, less_than: 150}
validate quantity, numericality: {greater_than_or_equal_to: 1}
validate price, numericality: {greater_than: 0}validate status, inclusion: {in: ["pending", "active", "archived"]}
validate role, inclusion: {in: ["admin", "user", "guest"]}validate username, exclusion: {in: ["admin", "root", "system"]}struct RegistrationRequest
include Azu::Request
getter username : String
getter password : String
getter email : String
def initialize(@username = "", @password = "", @email = "")
end
validate username, presence: true, length: {min: 3, max: 20}
validate password, presence: true, length: {min: 8}
validate email, presence: true, format: /@/
endstruct OrderRequest
include Azu::Request
getter items : Array(OrderItem)
getter coupon_code : String?
def initialize(@items = [] of OrderItem, @coupon_code = nil)
end
def validate
super # Run standard validations first
if items.empty?
errors << Error.new(:items, "must have at least one item")
end
if coupon = coupon_code
unless valid_coupon?(coupon)
errors << Error.new(:coupon_code, "is invalid or expired")
end
end
end
private def valid_coupon?(code : String) : Bool
Coupon.valid?(code)
end
endstruct PaymentRequest
include Azu::Request
getter payment_type : String
getter card_number : String?
getter bank_account : String?
def initialize(@payment_type = "", @card_number = nil, @bank_account = nil)
end
validate payment_type, presence: true
def validate
super
case payment_type
when "card"
if card_number.nil? || card_number.try(&.empty?)
errors << Error.new(:card_number, "is required for card payments")
end
when "bank"
if bank_account.nil? || bank_account.try(&.empty?)
errors << Error.new(:bank_account, "is required for bank payments")
end
end
end
endstruct CreateUserEndpoint
include Azu::Endpoint(CreateUserRequest, UserResponse)
post "/users"
def call : UserResponse
# Request is automatically validated before call
# Access validated data via the request object
user = User.create!(
name: create_user_request.name,
email: create_user_request.email,
age: create_user_request.age
)
status 201
UserResponse.new(user)
end
end{
"errors": [
{"field": "name", "message": "can't be blank"},
{"field": "email", "message": "is invalid"}
]
}struct ValidationErrorResponse
include Azu::Response
def initialize(@errors : Array(Azu::Request::Error))
end
def render
{
success: false,
error: {
code: "VALIDATION_FAILED",
details: @errors.map do |e|
{field: e.field.to_s, message: e.message}
end
}
}.to_json
end
endstruct CreateUserEndpoint
include Azu::Endpoint(CreateUserRequest, Azu::Response)
post "/users"
def call : Azu::Response
request = create_user_request
unless request.valid?
status 422
return ValidationErrorResponse.new(request.errors)
end
status 201
UserResponse.new(User.create!(request))
end
enddef call : Azu::Response
user = User.new(
name: create_user_request.name,
email: create_user_request.email
)
if user.save
status 201
UserResponse.new(user)
else
status 422
ModelErrorResponse.new(user.errors)
end
endclass ValidationErrorHandler < Azu::Handler::Base
def call(context)
call_next(context)
rescue error : Azu::Response::ValidationError
context.response.status_code = 422
context.response.content_type = "application/json"
context.response.print({
error: "Validation Failed",
details: error.errors.map { |e| {field: e.field, message: e.message} }
}.to_json)
end
endMyApp.start [
ValidationErrorHandler.new,
Azu::Handler::Rescuer.new,
# ... other handlers
]struct MultiFieldRequest
include Azu::Request
getter name : String
getter email : String
getter password : String
def initialize(@name = "", @email = "", @password = "")
end
validate name, presence: true, length: {min: 2}
validate email, presence: true, format: /@/
validate password, presence: true, length: {min: 8}
# All errors are collected, not just the first one
enddef call
request = create_user_request
unless request.valid?
return view "users/new.html", {
errors: request.errors,
form_data: request
}
end
redirect_to "/users"
end{% if errors %}
<div class="errors">
<ul>
{% for error in errors %}
<li>{{ error.field }}: {{ error.message }}</li>
{% endfor %}
</ul>
</div>
{% endif %}struct CreateUserRequest
include Azu::Request
getter name : String
def initialize(@name = "")
end
def validate
if name.empty?
errors << Error.new(:name, I18n.t("errors.name.blank"))
elsif name.size < 2
errors << Error.new(:name, I18n.t("errors.name.too_short", min: 2))
end
end
endstruct UserResponse
include Azu::Response
def initialize(@user : User)
end
def render
{
id: @user.id,
name: @user.name,
email: @user.email
}.to_json
end
endstruct TextEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Text)
get "/health"
def call
text "OK"
end
endstruct HtmlEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Html)
get "/page"
def call
html <<-HTML
<!DOCTYPE html>
<html>
<head><title>Page</title></head>
<body><h1>Hello!</h1></body>
</html>
HTML
end
endstruct TemplateEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Html)
include Azu::Templates::Renderable
get "/users"
def call
view "users/index.html", {
users: User.all,
title: "User List"
}
end
endstruct DeleteEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Empty)
delete "/users/:id"
def call
user = User.find(params["id"].to_i64)
user.try(&.delete)
status 204
Azu::Response::Empty.new
end
endstruct NegotiatedEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response)
get "/data"
def call
data = {message: "Hello", value: 42}
case accept_type
when "application/json"
json data
when "text/html"
html "<p>#{data[:message]}: #{data[:value]}</p>"
when "text/plain"
text "#{data[:message]}: #{data[:value]}"
else
json data # Default to JSON
end
end
private def accept_type
headers["Accept"]?.try(&.split(",").first) || "application/json"
end
enddef call
response.headers["X-Custom-Header"] = "value"
response.headers["Cache-Control"] = "max-age=3600"
json({data: "value"})
enddef call
redirect_to "/new-location"
# or
redirect_to "/new-location", status: 301 # Permanent redirect
endstruct DownloadEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response)
get "/download/:filename"
def call
filename = params["filename"]
file_path = File.join("files", filename)
if File.exists?(file_path)
response.headers["Content-Type"] = "application/octet-stream"
response.headers["Content-Disposition"] = "attachment; filename=\"#{filename}\""
response.body = File.read(file_path)
else
raise Azu::Response::NotFound.new("/download/#{filename}")
end
end
endmodule UserAPI
include Azu
configure do
# Use environment variables for all settings
port = ENV.fetch("PORT", "8080").to_i
host = ENV.fetch("HOST", "0.0.0.0")
# Production settings
if ENV["AZU_ENV"]? == "production"
log.level = Log::Severity::INFO
# SSL configuration
ssl_cert = ENV["SSL_CERT"]?
ssl_key = ENV["SSL_KEY"]?
else
log.level = Log::Severity::DEBUG
end
end
end# Build optimized binary
crystal build --release --no-debug src/user_api.cr -o bin/user_api
# Check binary size
ls -lh bin/user_apiAZU_ENV=production
PORT=8080
HOST=0.0.0.0
DATABASE_URL=postgres://user:password@db.example.com:5432/myapp_prod
REDIS_URL=redis://redis.example.com:6379/0
SECRET_KEY=your-secure-secret-key-here# Build stage
FROM crystallang/crystal:1.17.1-alpine AS builder
WORKDIR /app
# Copy dependency files
COPY shard.yml shard.lock ./
# Install dependencies
RUN shards install --production
# Copy source code
COPY src/ src/
# Build release binary
RUN crystal build --release --static --no-debug src/user_api.cr -o bin/user_api
# Runtime stage
FROM alpine:latest
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
# Copy binary from builder
COPY --from=builder /app/bin/user_api .
# Copy static files if any
COPY public/ public/
# Create non-root user
RUN adduser -D appuser
USER appuser
EXPOSE 8080
CMD ["./user_api"]version: "3.8"
services:
app:
build: .
ports:
- "8080:8080"
environment:
- AZU_ENV=production
- PORT=8080
- DATABASE_URL=postgres://user:password@db:5432/myapp_prod
- REDIS_URL=redis://redis:6379/0
depends_on:
- db
- redis
restart: unless-stopped
db:
image: postgres:15-alpine
environment:
- POSTGRES_DB=myapp_prod
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
restart: unless-stopped
volumes:
postgres_data:
redis_data:docker-compose build
docker-compose up -d[Unit]
Description=User API Application
After=network.target
[Service]
Type=simple
User=appuser
Group=appuser
WorkingDirectory=/opt/user-api
ExecStart=/opt/user-api/bin/user_api
Restart=always
RestartSec=5
Environment=AZU_ENV=production
Environment=PORT=8080
EnvironmentFile=/opt/user-api/.env
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable user-api
sudo systemctl start user-api
sudo systemctl status user-api# /etc/nginx/sites-available/user-api
server {
listen 80;
server_name api.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}sudo ln -s /etc/nginx/sites-available/user-api /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginxsudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d api.example.comstruct HealthEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Json)
get "/health"
def call
json({
status: "healthy",
timestamp: Time.utc.to_rfc3339,
version: "1.0.0"
})
end
end#!/bin/bash
set -e
echo "Starting deployment..."
# Pull latest code
git pull origin main
# Install dependencies
shards install --production
# Build application
crystal build --release --no-debug src/user_api.cr -o bin/user_api
# Run database migrations
./bin/user_api --migrate
# Restart service
sudo systemctl restart user-api
# Verify health
sleep 5
curl -f http://localhost:8080/health || exit 1
echo "Deployment completed successfully!"name: Deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Crystal
uses: crystal-lang/install-crystal@v1
with:
crystal: 1.17.1
- name: Install dependencies
run: shards install
- name: Run tests
run: crystal spec
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- name: Deploy to server
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
cd /opt/user-api
./scripts/deploy.shstruct MetricsEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Json)
get "/metrics"
def call
json({
uptime: Process.times.real.to_i,
memory_mb: GC.stats.heap_size / (1024 * 1024),
requests_total: RequestCounter.total
})
end
endclass SecurityHeaders < Azu::Handler::Base
def call(context)
response = context.response
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
call_next(context)
end
endclass User
include CQL::Model(User, Int64)
property id : Int64?
property name : String
property email : String
property age : Int32?
validate name, presence: true, length: {min: 2, max: 100}
validate email, presence: true, format: /@/
validate age, numericality: {greater_than: 0, less_than: 150}, allow_nil: true
endvalidate name, presence: truevalidate email, uniqueness: true
validate username, uniqueness: {scope: :organization_id}validate title, length: {min: 5, max: 200}
validate description, length: {max: 1000}validate email, format: {with: /\A[^@\s]+@[^@\s]+\z/}
validate slug, format: {with: /\A[a-z0-9-]+\z/}validate quantity, numericality: {greater_than_or_equal_to: 0}
validate price, numericality: {greater_than: 0}validate status, inclusion: {in: ["draft", "published", "archived"]}class Order
include CQL::Model(Order, Int64)
property id : Int64?
property user_id : Int64
property total : Float64
property items : Array(OrderItem)
def validate
super
if items.empty?
errors.add(:items, "must have at least one item")
end
if total <= 0
errors.add(:total, "must be positive")
end
validate_inventory
end
private def validate_inventory
items.each do |item|
product = Product.find(item.product_id)
if product && product.stock < item.quantity
errors.add(:items, "insufficient stock for #{product.name}")
end
end
end
endclass User
include CQL::Model(User, Int64)
property name : String
property email : String
property normalized_email : String?
before_validation :normalize_email
private def normalize_email
@normalized_email = email.downcase.strip
end
enduser = User.new(name: "", email: "invalid")
if user.valid?
user.save!
else
puts user.errors.full_messages
end
# Or use save with return value
if user.save
puts "Saved!"
else
puts user.errors.full_messages
end# Skip all validations
user.save!(validate: false)
# Update specific attribute without validation
user.update_column(:last_login, Time.utc)class User
include CQL::Model(User, Int64)
property password : String?
validate password, presence: true, on: :create
validate password, length: {min: 8}, on: :create
end
# Validations run on create
user = User.new(name: "Alice", email: "alice@example.com")
user.save # password validation runs
# Validations don't run on update for password
user.name = "Alice Smith"
user.save # password validation skippedclass ChatChannel < Azu::Channel
PATH = "/chat"
def on_connect
# Called when client connects
send({type: "connected", message: "Welcome!"}.to_json)
end
def on_message(message : String)
# Called when client sends a message
data = JSON.parse(message)
# Process message...
end
def on_close(code, reason)
# Called when connection closes
end
endMyApp.start [
Azu::Handler::Rescuer.new,
Azu::Handler::Logger.new,
ChatChannel.new,
# ... other handlers
]const socket = new WebSocket('ws://localhost:4000/chat');
socket.onopen = () => {
console.log('Connected');
};
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Received:', data);
};
socket.onclose = () => {
console.log('Disconnected');
};
// Send a message
socket.send(JSON.stringify({
type: 'message',
content: 'Hello!'
}));class ChatChannel < Azu::Channel
PATH = "/chat"
def on_message(message : String)
data = JSON.parse(message)
case data["type"]?.try(&.as_s)
when "message"
handle_chat_message(data)
when "typing"
handle_typing_indicator(data)
when "ping"
send({type: "pong"}.to_json)
else
send({type: "error", message: "Unknown message type"}.to_json)
end
rescue JSON::ParseException
send({type: "error", message: "Invalid JSON"}.to_json)
end
private def handle_chat_message(data)
content = data["content"]?.try(&.as_s) || ""
# Process chat message...
end
private def handle_typing_indicator(data)
# Handle typing indicator...
end
endclass ChatChannel < Azu::Channel
PATH = "/chat"
CONNECTIONS = [] of HTTP::WebSocket
def on_connect
CONNECTIONS << socket
broadcast_user_count
end
def on_close(code, reason)
CONNECTIONS.delete(socket)
broadcast_user_count
end
private def broadcast_user_count
message = {type: "users", count: CONNECTIONS.size}.to_json
CONNECTIONS.each(&.send(message))
end
endclass AuthenticatedChannel < Azu::Channel
PATH = "/secure"
@user : User?
def on_connect
token = context.request.query_params["token"]?
if token && (user = authenticate(token))
@user = user
send({type: "authenticated", user: user.name}.to_json)
else
send({type: "error", message: "Unauthorized"}.to_json)
socket.close
end
end
private def authenticate(token : String) : User?
# Validate token and return user
Token.validate(token)
end
endclass RoomChannel < Azu::Channel
PATH = "/rooms/:room_id"
@@rooms = Hash(String, Array(HTTP::WebSocket)).new { |h, k| h[k] = [] of HTTP::WebSocket }
def on_connect
room_id = params["room_id"]
@@rooms[room_id] << socket
broadcast_to_room(room_id, {type: "joined", room: room_id}.to_json)
end
def on_message(message : String)
room_id = params["room_id"]
broadcast_to_room(room_id, message)
end
def on_close(code, reason)
room_id = params["room_id"]
@@rooms[room_id].delete(socket)
end
private def broadcast_to_room(room_id : String, message : String)
@@rooms[room_id].each do |ws|
ws.send(message) unless ws == socket
end
end
endclass ChatChannel < Azu::Channel
PATH = "/chat"
def on_message(message : String)
process_message(message)
rescue ex : JSON::ParseException
send({type: "error", code: "INVALID_JSON"}.to_json)
rescue ex : Exception
Log.error { "WebSocket error: #{ex.message}" }
send({type: "error", code: "INTERNAL_ERROR"}.to_json)
end
endclass CounterComponent
include Azu::Component
@count = 0
def mount(socket)
# Called when component is mounted
push_state
end
def content
<<-HTML
<div id="counter">
<span>Count: #{@count}</span>
<button azu-click="increment">+</button>
<button azu-click="decrement">-</button>
</div>
HTML
end
on_event "increment" do
@count += 1
push_state
end
on_event "decrement" do
@count -= 1
push_state
end
end# In your application setup
Azu::Spark.register(CounterComponent)<script src="/azu/spark.js"></script>
<script>
Spark.connect('/spark');
</script><div azu-component="CounterComponent"></div>class UserCardComponent
include Azu::Component
property user_id : Int64
@user : User?
def mount(socket)
@user = User.find(user_id)
push_state
end
def content
if user = @user
<<-HTML
<div class="user-card">
<h3>#{user.name}</h3>
<p>#{user.email}</p>
</div>
HTML
else
"<div>User not found</div>"
end
end
end<div azu-component="UserCardComponent" azu-props='{"user_id": 123}'></div>class TodoFormComponent
include Azu::Component
@todos = [] of String
@input = ""
def content
<<-HTML
<div id="todo-form">
<input type="text"
azu-model="input"
value="#{@input}"
placeholder="Add todo...">
<button azu-click="add_todo">Add</button>
<ul>
#{@todos.map { |todo| "<li>#{todo}</li>" }.join}
</ul>
</div>
HTML
end
on_event "input_change" do |value|
@input = value.as_s
end
on_event "add_todo" do
unless @input.empty?
@todos << @input
@input = ""
push_state
end
end
endclass LifecycleComponent
include Azu::Component
def mount(socket)
# Called when component first mounts
load_initial_data
end
def unmount
# Called when component is removed
cleanup_resources
end
def before_update
# Called before state update
end
def after_update
# Called after state update
end
private def load_initial_data
# Load data from database, etc.
end
private def cleanup_resources
# Clean up subscriptions, etc.
end
endclass NotificationComponent
include Azu::Component
@notifications = [] of Notification
def mount(socket)
# Subscribe to notification channel
NotificationService.subscribe(current_user_id) do |notification|
@notifications.unshift(notification)
push_state
end
end
def content
<<-HTML
<div class="notifications">
#{@notifications.map { |n| render_notification(n) }.join}
</div>
HTML
end
private def render_notification(n : Notification)
<<-HTML
<div class="notification #{n.read? ? "" : "unread"}">
<p>#{n.message}</p>
<button azu-click="dismiss" azu-value="#{n.id}">Dismiss</button>
</div>
HTML
end
on_event "dismiss" do |id|
@notifications.reject! { |n| n.id == id.as_i }
push_state
end
endclass ListComponent
include Azu::Component
@items = [] of Item
def content
<<-HTML
<ul id="item-list">
#{@items.map { |item| render_item(item) }.join}
</ul>
HTML
end
on_event "add_item" do |data|
item = Item.new(data["name"].as_s)
@items << item
# Push only the new item instead of full re-render
push_append("#item-list", render_item(item))
end
private def render_item(item : Item)
%(<li id="item-#{item.id}">#{item.name}</li>)
end
endclass ParentComponent
include Azu::Component
@selected_id : Int64?
def content
<<-HTML
<div>
<div azu-component="ListComponent" azu-on-select="handle_select"></div>
<div azu-component="DetailComponent" azu-props='{"id": #{@selected_id}}'></div>
</div>
HTML
end
on_event "handle_select" do |id|
@selected_id = id.as_i64
push_state
end
endclass SafeComponent
include Azu::Component
@error : String?
def content
if error = @error
%(<div class="error">#{error}</div>)
else
render_content
end
end
on_event "risky_action" do
begin
perform_risky_action
rescue ex
@error = "Something went wrong: #{ex.message}"
push_state
end
end
end# db/migrations/001_create_users.cr
class CreateUsers < CQL::Migration
def up
create_table :users do
primary :id, Int64
column :name, String
column :email, String
timestamps
end
create_index :users, :email, unique: true
end
def down
drop_table :users
end
enddef up
create_table :posts do
primary :id, Int64
column :user_id, Int64
column :title, String
column :content, String
column :published, Bool, default: false
timestamps
foreign_key :user_id, :users, :id
end
enddef up
add_column :users, :bio, String?
add_column :users, :age, Int32, default: 0
end
def down
remove_column :users, :bio
remove_column :users, :age
enddef up
remove_column :users, :legacy_field
end
def down
add_column :users, :legacy_field, String?
enddef up
rename_column :users, :name, :full_name
end
def down
rename_column :users, :full_name, :name
enddef up
change_column :posts, :content, String, null: true
end
def down
change_column :posts, :content, String, null: false
enddef up
create_index :users, :email, unique: true
create_index :posts, [:user_id, :created_at]
end
def down
drop_index :users, :email
drop_index :posts, [:user_id, :created_at]
enddef up
add_foreign_key :posts, :user_id, :users, :id
end
def down
remove_foreign_key :posts, :user_id
enddef up
rename_table :posts, :articles
end
def down
rename_table :articles, :posts
enddef up
drop_table :legacy_data
end
def down
create_table :legacy_data do
primary :id, Int64
column :data, String
end
end# In your application or CLI
CQL::Migrator.new(AcmeDB).migrate# Using a custom CLI tool
crystal run src/cli.cr -- db:migrate# src/cli.cr
require "./db/schema"
require "./db/migrations/*"
case ARGV[0]?
when "db:migrate"
CQL::Migrator.new(AcmeDB).migrate
puts "Migrations complete"
when "db:rollback"
CQL::Migrator.new(AcmeDB).rollback
puts "Rollback complete"
when "db:reset"
CQL::Migrator.new(AcmeDB).reset
puts "Database reset"
else
puts "Usage: crystal run src/cli.cr -- [db:migrate|db:rollback|db:reset]"
endCQL::Migrator.new(AcmeDB).rollbackCQL::Migrator.new(AcmeDB).rollback(steps: 3)# Rollback all and re-migrate
CQL::Migrator.new(AcmeDB).resetdb/migrations/
βββ 001_create_users.cr
βββ 002_create_posts.cr
βββ 003_add_bio_to_users.cr
βββ 004_create_comments.crclass AddRoleToUsers < CQL::Migration
def up
add_column :users, :role, String, default: "user"
end
def down
remove_column :users, :role
end
endclass BackfillUserSlugs < CQL::Migration
def up
add_column :users, :slug, String?
# Backfill existing records
AcmeDB.exec("UPDATE users SET slug = lower(replace(name, ' ', '-'))")
# Make non-nullable after backfill
change_column :users, :slug, String
end
def down
remove_column :users, :slug
end
enddef up
add_column :posts, :word_count, Int32, default: 0
# Update in batches
offset = 0
batch_size = 1000
loop do
result = AcmeDB.exec(<<-SQL, offset, batch_size)
UPDATE posts
SET word_count = length(content) - length(replace(content, ' ', '')) + 1
WHERE id IN (SELECT id FROM posts LIMIT ? OFFSET ?)
SQL
break if result.rows_affected == 0
offset += batch_size
end
endclass TimingHandler < Azu::Handler::Base
def call(context)
start = Time.instant
call_next(context)
duration = Time.instant - start
context.response.headers["X-Response-Time"] = "#{duration.total_milliseconds.round(2)}ms"
end
enddependencies:
redis:
github: stefanwille/crystal-redis
version: ~> 2.9.0Azu.configure do |config|
config.cache = Azu::Cache::MemoryStore.new
end# src/db/schema.cr
AcmeDB = CQL::Schema.define(
:acme_db,
adapter: CQL::Adapter::SQLite,
uri: ENV.fetch("DATABASE_URL", "sqlite3://./db/development.db")
) do
table :users do
primary :id, Int64
column :name, String
column :email, String
column :created_at, Time, default: -> { Time.utc }
column :updated_at, Time, default: -> { Time.utc }
end
endAcmeDB.transaction do
user = User.create!(name: "Alice", email: "alice@example.com")
Profile.create!(user_id: user.id, bio: "Hello!")
Account.create!(user_id: user.id, balance: 0.0)
endMyApp.start [
Azu::Handler::Logger.new,
# ... other handlers
]# spec/spec_helper.cr
require "spec"
require "../src/app"
module TestHelpers
def create_context(
method : String = "GET",
path : String = "/",
body : String? = nil,
headers : HTTP::Headers = HTTP::Headers.new
) : HTTP::Server::Context
io = IO::Memory.new
request = HTTP::Request.new(method, path, headers, body)
response = HTTP::Server::Response.new(io)
HTTP::Server::Context.new(request, response)
end
def json_headers : HTTP::Headers
headers = HTTP::Headers.new
headers["Content-Type"] = "application/json"
headers
end
def with_auth(headers : HTTP::Headers, token : String) : HTTP::Headers
headers["Authorization"] = "Bearer #{token}"
headers
end
def parse_json_response(context) : JSON::Any
context.response.close
body = context.response.@io.as(IO::Memory).to_s
JSON.parse(body.split("\r\n\r\n").last)
end
endAzu.configure do |config|
# Enable in development
if ENV.fetch("AZU_ENV", "development") == "development"
config.template_hot_reload = true
end
endmodule FileValidator
ALLOWED_IMAGES = [".jpg", ".jpeg", ".png", ".gif", ".webp"]
ALLOWED_DOCUMENTS = [".pdf", ".doc", ".docx", ".txt"]
def self.valid_image?(filename : String) : Bool
ext = File.extname(filename).downcase
ALLOWED_IMAGES.includes?(ext)
end
def self.valid_document?(filename : String) : Bool
ext = File.extname(filename).downcase
ALLOWED_DOCUMENTS.includes?(ext)
end
endstruct HomeEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Html)
include Azu::Templates::Renderable
get "/"
def call
view "home/index.html", {
title: "Welcome",
message: "Hello, World!"
}
end
endMyApp.start [
TimingHandler.new, # First in chain
Azu::Handler::Rescuer.new,
Azu::Handler::Logger.new,
# ... endpoints
]class AuthHandler < Azu::Handler::Base
EXCLUDED_PATHS = ["/", "/login", "/health"]
def call(context)
path = context.request.path
if EXCLUDED_PATHS.includes?(path)
return call_next(context)
end
token = extract_token(context)
if token && valid_token?(token)
# Store user in context for later use
context.request.headers["X-User-ID"] = user_id_from_token(token).to_s
call_next(context)
else
context.response.status_code = 401
context.response.content_type = "application/json"
context.response.print({error: "Unauthorized"}.to_json)
end
end
private def extract_token(context) : String?
auth = context.request.headers["Authorization"]?
return nil unless auth
if auth.starts_with?("Bearer ")
auth[7..]
else
nil
end
end
private def valid_token?(token : String) : Bool
Token.valid?(token)
end
private def user_id_from_token(token : String) : Int64
Token.decode(token)["user_id"].as_i64
end
endclass CorsHandler < Azu::Handler::Base
def initialize(
@allowed_origins = ["*"],
@allowed_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
@allowed_headers = ["Content-Type", "Authorization"],
@max_age = 86400
)
end
def call(context)
origin = context.request.headers["Origin"]?
if origin && allowed_origin?(origin)
set_cors_headers(context, origin)
end
# Handle preflight
if context.request.method == "OPTIONS"
context.response.status_code = 204
return
end
call_next(context)
end
private def allowed_origin?(origin : String) : Bool
@allowed_origins.includes?("*") || @allowed_origins.includes?(origin)
end
private def set_cors_headers(context, origin)
headers = context.response.headers
headers["Access-Control-Allow-Origin"] = origin
headers["Access-Control-Allow-Methods"] = @allowed_methods.join(", ")
headers["Access-Control-Allow-Headers"] = @allowed_headers.join(", ")
headers["Access-Control-Max-Age"] = @max_age.to_s
end
endclass RateLimitHandler < Azu::Handler::Base
def initialize(
@limit = 100,
@window = 1.minute
)
end
def call(context)
client_id = get_client_id(context)
key = "ratelimit:#{client_id}"
current = increment_counter(key)
context.response.headers["X-RateLimit-Limit"] = @limit.to_s
context.response.headers["X-RateLimit-Remaining"] = Math.max(0, @limit - current).to_s
if current > @limit
context.response.status_code = 429
context.response.content_type = "application/json"
context.response.print({error: "Too many requests"}.to_json)
return
end
call_next(context)
end
private def get_client_id(context) : String
context.request.headers["X-Forwarded-For"]? ||
context.request.remote_address.to_s
end
private def increment_counter(key : String) : Int32
count = Azu.cache.increment(key)
Azu.cache.expire(key, @window) if count == 1
count
end
endclass RequestIdHandler < Azu::Handler::Base
def call(context)
request_id = context.request.headers["X-Request-ID"]? || generate_id
# Set on request for logging
context.request.headers["X-Request-ID"] = request_id
# Include in response
context.response.headers["X-Request-ID"] = request_id
call_next(context)
end
private def generate_id : String
UUID.random.to_s
end
endclass CompressionHandler < Azu::Handler::Base
MIN_SIZE = 1024 # Only compress responses > 1KB
def call(context)
call_next(context)
return unless should_compress?(context)
body = context.response.output.to_s
return if body.bytesize < MIN_SIZE
compressed = Compress::Gzip.compress(body)
context.response.headers["Content-Encoding"] = "gzip"
context.response.output = IO::Memory.new(compressed)
end
private def should_compress?(context) : Bool
accept = context.request.headers["Accept-Encoding"]?
return false unless accept
accept.includes?("gzip")
end
endclass ConditionalHandler < Azu::Handler::Base
def initialize(&@condition : HTTP::Server::Context -> Bool)
end
def call(context)
if @condition.call(context)
# Do something
end
call_next(context)
end
end
# Usage
ConditionalHandler.new { |ctx| ctx.request.path.starts_with?("/api") }MyApp.start [
RequestIdHandler.new, # First: Add request ID
TimingHandler.new, # Track timing
CorsHandler.new, # Handle CORS
RateLimitHandler.new, # Enforce limits
AuthHandler.new, # Authenticate
Azu::Handler::Logger.new, # Log requests
Azu::Handler::Rescuer.new, # Handle errors
# ... endpoints
]require "redis"
Azu.configure do |config|
config.cache = Azu::Cache::RedisStore.new(
url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")
)
end# Store a value
Azu.cache.set("user:1", user.to_json)
# Store with expiration
Azu.cache.set("session:abc", session_data, expires_in: 30.minutes)
# Retrieve
data = Azu.cache.get("user:1")user_json = Azu.cache.fetch("user:#{id}", expires_in: 1.hour) do
User.find(id).to_json
end# Single key
Azu.cache.delete("user:1")
# Pattern-based deletion
Azu.cache.delete_matched("user:*")# Increment a counter
Azu.cache.increment("page_views:home")
Azu.cache.increment("api_calls:user:1", by: 1)
# Decrement
Azu.cache.decrement("available_slots")Azu.configure do |config|
config.cache = Azu::Cache::RedisStore.new(
url: ENV["REDIS_URL"],
pool_size: 10,
pool_timeout: 5.seconds
)
endAzu::Cache::RedisStore.new(
url: "redis://localhost:6379/0",
pool_size: 10,
pool_timeout: 5.seconds,
default_ttl: 1.hour,
namespace: "myapp",
ssl: false,
password: ENV["REDIS_PASSWORD"]?
)# Production namespace
config.cache = Azu::Cache::RedisStore.new(
url: ENV["REDIS_URL"],
namespace: "myapp:production"
)
# Test namespace
config.cache = Azu::Cache::RedisStore.new(
url: ENV["REDIS_URL"],
namespace: "myapp:test"
)# Cache a user with associations
def cache_user(user : User)
data = {
id: user.id,
name: user.name,
email: user.email,
posts: user.posts.map { |p| {id: p.id, title: p.title} }
}
Azu.cache.set("user:#{user.id}:full", data.to_json, expires_in: 15.minutes)
end
# Retrieve
def get_cached_user(id : Int64)
json = Azu.cache.get("user:#{id}:full")
JSON.parse(json) if json
endstruct ProductEndpoint
include Azu::Endpoint(EmptyRequest, ProductResponse)
get "/products/:id"
def call : ProductResponse
product_id = params["id"]
# Try cache first
cached = Azu.cache.get("product:#{product_id}")
return ProductResponse.from_json(cached) if cached
# Cache miss - load from database
product = Product.find(product_id.to_i64)
response = ProductResponse.new(product)
# Store in cache
Azu.cache.set("product:#{product_id}", response.to_json, expires_in: 1.hour)
response
end
endclass Product
include CQL::Model(Product, Int64)
after_save :update_cache
after_destroy :remove_from_cache
private def update_cache
Azu.cache.set("product:#{id}", to_json, expires_in: 1.hour)
end
private def remove_from_cache
Azu.cache.delete("product:#{id}")
end
endclass RateLimiter
def self.allowed?(key : String, limit : Int32, window : Time::Span) : Bool
current = Azu.cache.increment("ratelimit:#{key}")
if current == 1
# Set expiration on first request
Azu.cache.expire("ratelimit:#{key}", window)
end
current <= limit
end
end
# Usage in endpoint
def call
unless RateLimiter.allowed?(client_ip, limit: 100, window: 1.minute)
raise Azu::Response::TooManyRequests.new
end
# Process request...
endclass SessionStore
def self.create(user_id : Int64) : String
session_id = Random::Secure.hex(32)
Azu.cache.set(
"session:#{session_id}",
{user_id: user_id, created_at: Time.utc}.to_json,
expires_in: 24.hours
)
session_id
end
def self.get(session_id : String) : Int64?
data = Azu.cache.get("session:#{session_id}")
return nil unless data
JSON.parse(data)["user_id"].as_i64
end
def self.destroy(session_id : String)
Azu.cache.delete("session:#{session_id}")
end
endstruct HealthEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Json)
get "/health"
def call
redis_ok = begin
Azu.cache.set("health_check", "ok", expires_in: 1.second)
true
rescue
false
end
json({
status: redis_ok ? "healthy" : "degraded",
redis: redis_ok
})
end
end# Store a value
Azu.cache.set("user:1", user.to_json)
# Retrieve a value
json = Azu.cache.get("user:1")
if json
user = User.from_json(json)
end
# With expiration (TTL)
Azu.cache.set("session:abc123", session_data, expires_in: 30.minutes)user = Azu.cache.fetch("user:#{id}", expires_in: 1.hour) do
User.find(id).to_json
end# Delete a single key
Azu.cache.delete("user:1")
# Delete multiple keys
["user:1", "user:2", "user:3"].each do |key|
Azu.cache.delete(key)
endif Azu.cache.exists?("user:1")
# Key exists
end# Clear entire cache
Azu.cache.clearstruct UserEndpoint
include Azu::Endpoint(EmptyRequest, UserResponse)
get "/users/:id"
def call : UserResponse
user_id = params["id"]
cache_key = "user:#{user_id}"
cached = Azu.cache.get(cache_key)
if cached
return UserResponse.from_json(cached)
end
user = User.find(user_id.to_i64)
response = UserResponse.new(user)
Azu.cache.set(cache_key, response.to_json, expires_in: 10.minutes)
response
end
endAzu.configure do |config|
config.cache = Azu::Cache::MemoryStore.new(
max_size: 10_000, # Maximum number of entries
default_ttl: 1.hour, # Default expiration
cleanup_interval: 5.minutes # How often to clean expired entries
)
endmodule CacheKeys
def self.user(id)
"users:#{id}"
end
def self.user_posts(user_id)
"users:#{user_id}:posts"
end
def self.post(id)
"posts:#{id}"
end
end
# Usage
Azu.cache.set(CacheKeys.user(user.id), user.to_json)class User
include CQL::Model(User, Int64)
after_save :invalidate_cache
after_destroy :invalidate_cache
private def invalidate_cache
Azu.cache.delete("user:#{id}")
Azu.cache.delete("user:#{id}:posts")
Azu.cache.delete("users:list")
end
end# Safe to use from multiple fibers
spawn { Azu.cache.set("key1", "value1") }
spawn { Azu.cache.set("key2", "value2") }
spawn { Azu.cache.get("key1") }stats = Azu.cache.stats
puts "Hits: #{stats.hits}"
puts "Misses: #{stats.misses}"
puts "Hit rate: #{stats.hit_rate}%"
puts "Size: #{stats.size} entries"table :products do
primary :id, Int64
column :name, String # VARCHAR/TEXT
column :description, String? # Nullable
column :price, Float64 # REAL/DOUBLE
column :quantity, Int32 # INTEGER
column :active, Bool, default: true # BOOLEAN
column :metadata, JSON::Any? # JSON
endtable :posts do
primary :id, Int64
column :title, String
timestamps # Adds created_at and updated_at
endtable :orders do
primary :id, Int64
column :status, String, default: "pending"
column :order_number, String, default: -> { generate_order_number }
column :created_at, Time, default: -> { Time.utc }
endtable :posts do
primary :id, Int64
column :user_id, Int64
column :title, String
column :content, String
foreign_key :user_id, :users, :id
endtable :post_tags do
primary :id, Int64
column :post_id, Int64
column :tag_id, Int64
foreign_key :post_id, :posts, :id
foreign_key :tag_id, :tags, :id
index [:post_id, :tag_id], unique: true
endtable :users do
primary :id, Int64
column :email, String
column :username, String
column :organization_id, Int64
index :email, unique: true
index :username, unique: true
index :organization_id # Non-unique index
index [:organization_id, :username], unique: true # Composite
endAcmeDB = CQL::Schema.define(:acme_db, adapter: CQL::Adapter::SQLite, uri: db_uri) do
table :users do
primary :id, Int64
column :name, String
column :email, String
timestamps
end
table :posts do
primary :id, Int64
column :user_id, Int64
column :title, String
column :content, String
column :published, Bool, default: false
timestamps
foreign_key :user_id, :users, :id
index :user_id
end
table :comments do
primary :id, Int64
column :post_id, Int64
column :user_id, Int64
column :content, String
timestamps
foreign_key :post_id, :posts, :id
foreign_key :user_id, :users, :id
end
endCQL::Schema.define(:mydb, adapter: CQL::Adapter::SQLite, uri: "sqlite3://./db/app.db")CQL::Schema.define(:mydb, adapter: CQL::Adapter::Postgres, uri: ENV["DATABASE_URL"])CQL::Schema.define(:mydb, adapter: CQL::Adapter::MySql, uri: ENV["DATABASE_URL"])def database_uri
case ENV.fetch("AZU_ENV", "development")
when "production"
ENV["DATABASE_URL"]
when "test"
"sqlite3://./db/test.db"
else
"sqlite3://./db/development.db"
end
end
AcmeDB = CQL::Schema.define(:acme_db, adapter: CQL::Adapter::SQLite, uri: database_uri)user = AcmeDB.transaction do
user = User.create!(name: "Alice", email: "alice@example.com")
Profile.create!(user_id: user.id)
user # Return the user
end
puts user.id # Use the created userAcmeDB.transaction do |tx|
user = User.create!(name: "Alice", email: "alice@example.com")
if some_condition_fails
tx.rollback
next # Exit the block
end
complete_setup(user)
endbegin
AcmeDB.transaction do
transfer_funds(from_account, to_account, amount)
end
puts "Transfer successful"
rescue ex : CQL::TransactionError
puts "Transaction failed: #{ex.message}"
rescue ex : CQL::RecordInvalid
puts "Validation failed: #{ex.message}"
endAcmeDB.transaction do
user = User.create!(name: "Alice", email: "alice@example.com")
AcmeDB.transaction(savepoint: true) do
# This is a savepoint, not a new transaction
create_optional_resources(user)
rescue
# Only this inner block is rolled back
Log.warn { "Optional resources failed" }
end
# User creation is preserved
create_required_resources(user)
endAcmeDB.transaction(isolation: :serializable) do
# Highest isolation level
process_financial_transaction
end
# Available levels:
# :read_uncommitted
# :read_committed
# :repeatable_read
# :serializableAcmeDB.transaction do
# Lock the row for update
account = Account.lock.find(account_id)
# No other transaction can modify this row
account.balance -= amount
account.save!
endAcmeDB.transaction do
accounts = Account.where(user_id: user_id)
.lock("FOR UPDATE")
.all
accounts.each do |account|
process_account(account)
end
enddef transfer(from_id : Int64, to_id : Int64, amount : Float64)
raise "Invalid amount" if amount <= 0
AcmeDB.transaction do
# Lock both accounts to prevent race conditions
from = Account.lock.find(from_id)
to = Account.lock.find(to_id)
raise "Insufficient funds" if from.balance < amount
from.balance -= amount
to.balance += amount
from.save!
to.save!
# Record the transaction
Transaction.create!(
from_account_id: from_id,
to_account_id: to_id,
amount: amount,
completed_at: Time.utc
)
end
enddef import_users(records : Array(Hash))
# Process in batches of 100
records.each_slice(100) do |batch|
AcmeDB.transaction do
batch.each do |data|
User.create!(
name: data["name"],
email: data["email"]
)
end
end
end
endclass Order
include CQL::Model(Order, Int64)
after_commit :send_confirmation_email, on: :create
after_rollback :log_failure
private def send_confirmation_email
# Only runs if transaction commits
Mailer.order_confirmation(self).deliver
end
private def log_failure
Log.error { "Order creation rolled back: #{id}" }
end
endAcmeDB.transaction(read_only: true) do
# Optimized for reads, no write locks
generate_report
end# Good: Short, focused transaction
AcmeDB.transaction do
order.status = "completed"
order.save!
inventory.reduce!(order.items)
end
# Then do external operations
send_notification(order) # Outside transactionAzu.configure do |config|
case ENV.fetch("AZU_ENV", "development")
when "production"
config.log.level = Log::Severity::Info
when "test"
config.log.level = Log::Severity::Warn
else
config.log.level = Log::Severity::Debug
end
endclass StructuredLogger < Azu::Handler::Base
def call(context)
start = Time.instant
request_id = context.request.headers["X-Request-ID"]?
begin
call_next(context)
ensure
duration = Time.instant - start
log_request(context, duration, request_id)
end
end
private def log_request(context, duration, request_id)
Log.info { {
request_id: request_id,
method: context.request.method,
path: context.request.path,
status: context.response.status_code,
duration_ms: duration.total_milliseconds.round(2),
remote_ip: client_ip(context),
user_agent: context.request.headers["User-Agent"]?
}.to_json }
end
private def client_ip(context) : String
context.request.headers["X-Forwarded-For"]?.try(&.split(",").first.strip) ||
context.request.remote_address.to_s
end
endstruct CreateUserEndpoint
include Azu::Endpoint(CreateUserRequest, UserResponse)
post "/users"
def call : UserResponse
Log.debug { "Creating user with email: #{create_user_request.email}" }
user = User.create!(create_user_request)
Log.info { "User created: id=#{user.id}, email=#{user.email}" }
UserResponse.new(user)
rescue ex
Log.error(exception: ex) { "Failed to create user" }
raise ex
end
endLog.setup do |config|
file_backend = Log::IOBackend.new(File.new("log/app.log", "a"))
config.bind "azu.*", :info, file_backend
config.bind "*", :info, file_backend
endclass JsonLogBackend < Log::Backend
def initialize(@io : IO = STDOUT)
end
def write(entry : Log::Entry)
data = {
timestamp: entry.timestamp.to_rfc3339,
severity: entry.severity.to_s,
source: entry.source,
message: entry.message,
data: entry.data.to_h,
}
if ex = entry.exception
data = data.merge({
exception: ex.class.name,
exception_message: ex.message,
backtrace: ex.backtrace?.try(&.first(10))
})
end
@io.puts data.to_json
end
end
Log.setup do |config|
config.bind "*", :info, JsonLogBackend.new
endLog.setup do |config|
stdout = Log::IOBackend.new
file = Log::IOBackend.new(File.new("log/app.log", "a"))
# Development: stdout only
if ENV["AZU_ENV"]? == "development"
config.bind "*", :debug, stdout
else
# Production: file for all, stdout for errors
config.bind "*", :info, file
config.bind "*", :error, stdout
end
endclass ContextLogger
Log = ::Log.for(self)
def self.with_context(request_id : String, user_id : Int64? = nil, &)
Log.context.set(request_id: request_id)
Log.context.set(user_id: user_id.to_s) if user_id
yield
ensure
Log.context.clear
end
end
# Usage in handler
class RequestContextHandler < Azu::Handler::Base
def call(context)
request_id = context.request.headers["X-Request-ID"]? || UUID.random.to_s
user_id = context.request.headers["X-User-ID"]?.try(&.to_i64)
ContextLogger.with_context(request_id, user_id) do
call_next(context)
end
end
endclass ErrorLogger < Azu::Handler::Base
Log = ::Log.for(self)
def call(context)
call_next(context)
rescue ex
log_error(context, ex)
raise ex
end
private def log_error(context, ex : Exception)
Log.error(exception: ex) { {
error: ex.class.name,
message: ex.message,
path: context.request.path,
method: context.request.method,
request_id: context.request.headers["X-Request-ID"]?
}.to_json }
end
endmodule LogFilter
SENSITIVE_KEYS = ["password", "token", "secret", "api_key", "authorization"]
def self.filter(data : Hash) : Hash
data.transform_values do |value|
case value
when Hash
filter(value)
when String
SENSITIVE_KEYS.any? { |k| data.keys.any?(&.downcase.includes?(k)) } ? "[FILTERED]" : value
else
value
end
end
end
end# /etc/logrotate.d/myapp
/var/log/myapp/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
create 0640 appuser appuser
}class SlowRequestLogger < Azu::Handler::Base
THRESHOLD = 1.second
def call(context)
start = Time.instant
call_next(context)
duration = Time.instant - start
if duration > THRESHOLD
Log.warn { "Slow request: #{context.request.method} #{context.request.path} took #{duration.total_seconds.round(2)}s" }
end
end
end# spec/endpoints/show_user_endpoint_spec.cr
require "../spec_helper"
describe ShowUserEndpoint do
include TestHelpers
before_each do
User.delete_all
end
describe "#call" do
it "returns user when found" do
user = User.create!(name: "Alice", email: "alice@example.com")
context = create_context("GET", "/users/#{user.id}")
endpoint = ShowUserEndpoint.new
endpoint.context = context
endpoint.params = {"id" => user.id.to_s}
response = endpoint.call
response.should be_a(UserResponse)
context.response.status_code.should eq(200)
end
it "returns 404 for non-existent user" do
context = create_context("GET", "/users/999")
endpoint = ShowUserEndpoint.new
endpoint.context = context
endpoint.params = {"id" => "999"}
expect_raises(Azu::Response::NotFound) do
endpoint.call
end
end
end
end# spec/endpoints/create_user_endpoint_spec.cr
require "../spec_helper"
describe CreateUserEndpoint do
include TestHelpers
before_each do
User.delete_all
end
describe "#call" do
it "creates user with valid data" do
body = {name: "Alice", email: "alice@example.com", age: 30}.to_json
context = create_context("POST", "/users", body, json_headers)
endpoint = CreateUserEndpoint.new
endpoint.context = context
response = endpoint.call
response.should be_a(UserResponse)
context.response.status_code.should eq(201)
User.count.should eq(1)
end
it "returns validation error for missing name" do
body = {email: "alice@example.com"}.to_json
context = create_context("POST", "/users", body, json_headers)
endpoint = CreateUserEndpoint.new
endpoint.context = context
expect_raises(Azu::Response::ValidationError) do
endpoint.call
end
end
it "returns validation error for invalid email" do
body = {name: "Alice", email: "invalid"}.to_json
context = create_context("POST", "/users", body, json_headers)
endpoint = CreateUserEndpoint.new
endpoint.context = context
expect_raises(Azu::Response::ValidationError) do
endpoint.call
end
end
end
enddescribe UpdateUserEndpoint do
include TestHelpers
it "updates user attributes" do
user = User.create!(name: "Alice", email: "alice@example.com")
body = {name: "Alice Smith"}.to_json
context = create_context("PUT", "/users/#{user.id}", body, json_headers)
endpoint = UpdateUserEndpoint.new
endpoint.context = context
endpoint.params = {"id" => user.id.to_s}
response = endpoint.call
response.should be_a(UserResponse)
User.find(user.id).name.should eq("Alice Smith")
end
enddescribe DeleteUserEndpoint do
include TestHelpers
it "deletes the user" do
user = User.create!(name: "Alice", email: "alice@example.com")
context = create_context("DELETE", "/users/#{user.id}")
endpoint = DeleteUserEndpoint.new
endpoint.context = context
endpoint.params = {"id" => user.id.to_s}
endpoint.call
context.response.status_code.should eq(204)
User.find?(user.id).should be_nil
end
enddescribe ProtectedEndpoint do
include TestHelpers
it "returns 401 without token" do
context = create_context("GET", "/protected")
endpoint = ProtectedEndpoint.new
endpoint.context = context
expect_raises(Azu::Response::Unauthorized) do
endpoint.call
end
end
it "succeeds with valid token" do
user = User.create!(name: "Alice", email: "alice@example.com")
token = Token.create(user_id: user.id)
headers = with_auth(json_headers, token)
context = create_context("GET", "/protected", nil, headers)
endpoint = ProtectedEndpoint.new
endpoint.context = context
response = endpoint.call
context.response.status_code.should eq(200)
end
end# spec/integration/users_api_spec.cr
require "../spec_helper"
require "http/client"
describe "Users API" do
BASE_URL = "http://localhost:4000"
before_all do
# Start server in background
spawn { MyApp.start }
sleep 1.second
end
describe "POST /users" do
it "creates a new user" do
response = HTTP::Client.post(
"#{BASE_URL}/users",
headers: HTTP::Headers{"Content-Type" => "application/json"},
body: {name: "Test User", email: "test@example.com"}.to_json
)
response.status_code.should eq(201)
data = JSON.parse(response.body)
data["name"].should eq("Test User")
data["email"].should eq("test@example.com")
end
end
describe "GET /users/:id" do
it "returns the user" do
# Create user first
create_response = HTTP::Client.post(
"#{BASE_URL}/users",
headers: HTTP::Headers{"Content-Type" => "application/json"},
body: {name: "Test", email: "test@example.com"}.to_json
)
user_id = JSON.parse(create_response.body)["id"]
# Get user
response = HTTP::Client.get("#{BASE_URL}/users/#{user_id}")
response.status_code.should eq(200)
JSON.parse(response.body)["id"].should eq(user_id)
end
end
endit "returns correct JSON structure" do
user = User.create!(name: "Alice", email: "alice@example.com")
context = create_context("GET", "/users/#{user.id}")
endpoint = ShowUserEndpoint.new
endpoint.context = context
endpoint.params = {"id" => user.id.to_s}
endpoint.call
json = parse_json_response(context)
json["id"].should eq(user.id)
json["name"].should eq("Alice")
json["email"].should eq("alice@example.com")
json.as_h.has_key?("password").should be_false
end# Run all tests
crystal spec
# Run specific file
crystal spec spec/endpoints/create_user_endpoint_spec.cr
# Run with verbose output
crystal spec --verbose
# Run tagged tests
crystal spec --tag focusAzu.configure do |config|
case ENV.fetch("AZU_ENV", "development")
when "development"
config.template_hot_reload = true
config.log.level = Log::Severity::Debug
when "production"
config.template_hot_reload = false
config.log.level = Log::Severity::Info
end
end# Install watchexec
brew install watchexec # macOS
# or
cargo install watchexec-cli
# Watch for changes and restart
watchexec -r -e cr,html crystal run src/app.cr# Install entr
brew install entr # macOS
# Watch and restart
find src views -name "*.cr" -o -name "*.html" | entr -r crystal run src/app.crclass LiveReloadChannel < Azu::Channel
PATH = "/livereload"
CONNECTIONS = [] of HTTP::WebSocket
def on_connect
CONNECTIONS << socket
end
def on_close(code, reason)
CONNECTIONS.delete(socket)
end
def self.trigger_reload
CONNECTIONS.each do |ws|
ws.send({type: "reload"}.to_json)
end
end
end<script>
if (location.hostname === 'localhost') {
const ws = new WebSocket('ws://localhost:4000/livereload');
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.type === 'reload') {
location.reload();
}
};
}
</script><!-- Include in development only -->
{% if env == "development" %}
<script src="http://localhost:35729/livereload.js"></script>
{% endif %}# Install livereload
npm install -g livereload
# Start watching
livereload views/# scripts/dev.cr
require "file_utils"
WATCH_DIRS = ["src", "views"]
EXTENSIONS = [".cr", ".html", ".css", ".js"]
def run_server
Process.new(
"crystal",
["run", "src/app.cr"],
output: STDOUT,
error: STDERR
)
end
def file_changed?(path : String) : Bool
EXTENSIONS.any? { |ext| path.ends_with?(ext) }
end
server = run_server
loop do
# Simple polling approach
sleep 1.second
changed = false
WATCH_DIRS.each do |dir|
Dir.glob("#{dir}/**/*").each do |file|
if file_changed?(file) && File.info(file).modification_time > 1.second.ago
changed = true
break
end
end
end
if changed
puts "Change detected, restarting..."
server.signal(:term)
server = run_server
end
end# shard.yml
development_dependencies:
sentry:
github: samueleaton/sentry# .sentry.yml
info: true
src: src
output_name: app
watch:
- ./src/**/*.cr
- ./views/**/*.html
run: ./app
build: crystal build src/app.cr -o appsentry# Always disable in production
if ENV["AZU_ENV"] == "production"
config.template_hot_reload = false
endALLOWED_CONTENT_TYPES = {
"image/jpeg" => [".jpg", ".jpeg"],
"image/png" => [".png"],
"image/gif" => [".gif"],
"application/pdf" => [".pdf"],
}
def valid_content_type?(file : HTTP::FormData::File) : Bool
content_type = file.headers["Content-Type"]?
return false unless content_type
filename = file.filename || ""
ext = File.extname(filename).downcase
if allowed_exts = ALLOWED_CONTENT_TYPES[content_type]?
allowed_exts.includes?(ext)
else
false
end
endmodule MagicNumber
SIGNATURES = {
jpeg: Bytes[0xFF, 0xD8, 0xFF],
png: Bytes[0x89, 0x50, 0x4E, 0x47],
gif: Bytes[0x47, 0x49, 0x46],
pdf: Bytes[0x25, 0x50, 0x44, 0x46],
zip: Bytes[0x50, 0x4B, 0x03, 0x04],
}
def self.detect(io : IO) : Symbol?
buffer = Bytes.new(8)
io.read(buffer)
io.rewind # Reset position
SIGNATURES.each do |type, signature|
if buffer[0, signature.size] == signature
return type
end
end
nil
end
def self.image?(io : IO) : Bool
type = detect(io)
[:jpeg, :png, :gif].includes?(type)
end
def self.pdf?(io : IO) : Bool
detect(io) == :pdf
end
endclass FileValidationError < Exception; end
module SecureFileValidator
ALLOWED_TYPES = {
image: {
extensions: [".jpg", ".jpeg", ".png", ".gif"],
content_types: ["image/jpeg", "image/png", "image/gif"],
magic_check: ->(io : IO) { MagicNumber.image?(io) }
},
document: {
extensions: [".pdf"],
content_types: ["application/pdf"],
magic_check: ->(io : IO) { MagicNumber.pdf?(io) }
}
}
def self.validate!(file : HTTP::FormData::File, type : Symbol)
config = ALLOWED_TYPES[type]?
raise FileValidationError.new("Unknown file type category") unless config
filename = file.filename || "unknown"
ext = File.extname(filename).downcase
content_type = file.headers["Content-Type"]?
# Check extension
unless config[:extensions].includes?(ext)
raise FileValidationError.new("Invalid file extension: #{ext}")
end
# Check content type
unless content_type && config[:content_types].includes?(content_type)
raise FileValidationError.new("Invalid content type: #{content_type}")
end
# Check magic number
unless config[:magic_check].call(file.body)
raise FileValidationError.new("File content does not match declared type")
end
true
end
endstruct ImageUploadRequest
include Azu::Request
ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif"]
MAX_SIZE = 5 * 1024 * 1024 # 5 MB
getter image : HTTP::FormData::File
def initialize(@image)
end
def validate
super
validate_extension
validate_size
validate_content
end
private def validate_extension
ext = File.extname(image.filename || "").downcase
unless ALLOWED_EXTENSIONS.includes?(ext)
errors << Error.new(:image, "must be JPG, PNG, or GIF")
end
end
private def validate_size
if image.body.size > MAX_SIZE
errors << Error.new(:image, "must be smaller than 5 MB")
end
end
private def validate_content
unless MagicNumber.image?(image.body)
errors << Error.new(:image, "is not a valid image file")
end
end
enddef safe_filename(original : String) : String
# Remove path components
name = File.basename(original)
# Remove dangerous characters
name = name.gsub(/[^a-zA-Z0-9._-]/, "_")
# Ensure it doesn't start with a dot
name = "_#{name}" if name.starts_with?(".")
# Add unique prefix
"#{UUID.random}_#{name}"
enddef validate_filename(name : String) : Bool
# Reject double extensions like "file.php.jpg"
parts = name.split(".")
return false if parts.size > 2
# Reject executable extensions anywhere
dangerous = [".php", ".exe", ".sh", ".bat", ".cmd", ".js"]
parts.none? { |p| dangerous.includes?(".#{p.downcase}") }
enddef validate_image_dimensions(file : HTTP::FormData::File, max_width = 4096, max_height = 4096)
# Use ImageMagick identify
result = Process.run("identify", ["-format", "%wx%h", "-"], input: file.body)
dimensions = result.output.to_s.strip.split("x")
width = dimensions[0].to_i
height = dimensions[1].to_i
file.body.rewind
width <= max_width && height <= max_height
endmodule VirusScanner
def self.scan(file_path : String) : Bool
result = Process.run("clamscan", ["--no-summary", file_path])
result.exit_code == 0 # 0 means no virus found
end
def self.scan_io(io : IO) : Bool
# Save to temp file for scanning
temp_path = File.tempname("scan")
File.write(temp_path, io.gets_to_end)
io.rewind
result = scan(temp_path)
File.delete(temp_path)
result
end
endviews/
βββ layouts/
β βββ application.html
βββ home/
β βββ index.html
βββ users/
β βββ index.html
β βββ show.html
β βββ edit.html
βββ shared/
βββ _header.html
βββ _footer.html<!-- views/home/index.html -->
<!DOCTYPE html>
<html>
<head>
<title>{{ title }}</title>
</head>
<body>
<h1>{{ message }}</h1>
{% if user %}
<p>Welcome, {{ user.name }}!</p>
{% else %}
<p>Please log in.</p>
{% endif %}
</body>
</html>def call
view "users/show.html", {
user: User.find(params["id"]),
posts: user.posts.recent.all,
is_admin: current_user.admin?
}
end<h1>{{ user.name }}</h1>
<p>Email: {{ user.email }}</p>
{% if is_admin %}
<a href="/admin">Admin Panel</a>
{% endif %}
<h2>Recent Posts</h2>
<ul>
{% for post in posts %}
<li>{{ post.title }}</li>
{% endfor %}
</ul>{% for item in items %}
<div class="item">
<span>{{ loop.index }}.</span>
<span>{{ item.name }}</span>
</div>
{% else %}
<p>No items found.</p>
{% endfor %}{% if user.admin %}
<span class="badge">Admin</span>
{% elif user.moderator %}
<span class="badge">Moderator</span>
{% else %}
<span class="badge">User</span>
{% endif %}{{ name | upper }}
{{ description | truncate(100) }}
{{ price | round(2) }}
{{ created_at | date("%Y-%m-%d") }}
{{ content | escape }}
{{ list | join(", ") }}
{{ text | default("N/A") }}<!-- views/layouts/application.html -->
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}My App{% endblock %}</title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<header>
{% include "shared/_header.html" %}
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer>
{% include "shared/_footer.html" %}
</footer>
</body>
</html><!-- views/users/index.html -->
{% extends "layouts/application.html" %}
{% block title %}Users - My App{% endblock %}
{% block content %}
<h1>Users</h1>
<ul>
{% for user in users %}
<li>{{ user.name }}</li>
{% endfor %}
</ul>
{% endblock %}<!-- views/shared/_user_card.html -->
<div class="user-card">
<img src="{{ user.avatar_url }}" alt="{{ user.name }}">
<h3>{{ user.name }}</h3>
<p>{{ user.bio }}</p>
</div>{% for user in users %}
{% include "shared/_user_card.html" with user=user %}
{% endfor %}{% macro input(name, value="", type="text") %}
<input type="{{ type }}" name="{{ name }}" value="{{ value }}" class="form-input">
{% endmacro %}
{{ input("email", user.email, "email") }}
{{ input("password", type="password") }}{# This is a comment and won't be rendered #}
{#
Multi-line
comment
#}{% raw %}
This {{ will not }} be processed
{% endraw %}Azu::Templates.register_function("format_currency") do |args|
amount = args[0].as_f
"$#{sprintf("%.2f", amount)}"
end{{ format_currency(order.total) }}# Build stage
FROM crystallang/crystal:1.17.1-alpine AS builder
WORKDIR /app
# Copy dependency files
COPY shard.yml shard.lock ./
# Install dependencies
RUN shards install --production
# Copy source code
COPY src/ src/
# Build release binary
RUN crystal build --release --static --no-debug src/app.cr -o bin/app
# Runtime stage
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
# Copy binary from builder
COPY --from=builder /app/bin/app .
# Copy static assets if any
COPY public/ public/
COPY views/ views/
# Create non-root user
RUN adduser -D -u 1000 appuser
USER appuser
EXPOSE 8080
ENV AZU_ENV=production
ENV PORT=8080
CMD ["./app"]class NotFoundError < Azu::Response::Error
def initialize(resource : String, id : String | Int64)
super("#{resource} with id #{id} not found", 404)
end
end
# Usage
raise NotFoundError.new("User", params["id"])# Bad: In-memory state
@@users_cache = {} of Int64 => User
# Good: External cache
Azu.cache.set("user:#{id}", user.to_json)# spec/support/mock_websocket.cr
class MockWebSocket
getter sent_messages = [] of String
getter closed = false
getter close_code : Int32?
getter close_reason : String?
def send(message : String)
@sent_messages << message
end
def close(code : Int32? = nil, reason : String? = nil)
@closed = true
@close_code = code
@close_reason = reason
end
def object_id
0_u64
end
endmodule MyApp
include Azu
configure do
port = ENV.fetch("PORT", "8080").to_i
host = ENV.fetch("HOST", "0.0.0.0")
case ENV.fetch("AZU_ENV", "development")
when "production"
log.level = Log::Severity::Info
template_hot_reload = false
when "test"
log.level = Log::Severity::Warn
else
log.level = Log::Severity::Debug
template_hot_reload = true
end
end
endclass MyComponent
include Azu::Component
def content
"<div>Hello</div>"
end
endclass MyChannel < Azu::Channel
PATH = "/ws/path"
def on_connect
# Handle connection
end
def on_message(message : String)
# Handle message
end
def on_close(code, reason)
# Handle disconnection
end
endstruct MyRequest
include Azu::Request
getter field1 : String
getter field2 : Int32?
def initialize(@field1 = "", @field2 = nil)
end
endstruct MyEndpoint
include Azu::Endpoint(EmptyRequest, MyResponse)
get "/path" # Registers GET /path
def call : MyResponse
# Handle request
end
end.git
.github
*.md
docs/
spec/
tmp/
log/
.env*
!.env.example
bin/
lib/
.crystal/# Build the image
docker build -t myapp:latest .
# Run the container
docker run -p 8080:8080 \
-e DATABASE_URL=postgres://... \
-e REDIS_URL=redis://... \
myapp:latest
# Run with env file
docker run -p 8080:8080 --env-file .env.production myapp:latestversion: "3.8"
services:
app:
build: .
ports:
- "8080:8080"
environment:
- AZU_ENV=production
- PORT=8080
- DATABASE_URL=postgres://user:password@db:5432/myapp
- REDIS_URL=redis://redis:6379/0
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
db:
image: postgres:15-alpine
environment:
- POSTGRES_DB=myapp
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d myapp"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
restart: unless-stopped
volumes:
postgres_data:
redis_data:version: "3.8"
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "4000:4000"
volumes:
- .:/app
- /app/lib # Exclude lib directory
environment:
- AZU_ENV=development
- PORT=4000
- DATABASE_URL=postgres://user:password@db:5432/myapp_dev
depends_on:
- db
- redis
db:
image: postgres:15-alpine
environment:
- POSTGRES_DB=myapp_dev
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
ports:
- "5432:5432"
volumes:
- postgres_dev_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
postgres_dev_data:# Dockerfile.dev
FROM crystallang/crystal:1.17.1
WORKDIR /app
RUN apt-get update && apt-get install -y watchexec
COPY shard.yml shard.lock ./
RUN shards install
COPY . .
CMD ["watchexec", "-r", "-e", "cr", "crystal", "run", "src/app.cr"]version: "3.8"
services:
app:
build: .
expose:
- "8080"
environment:
- AZU_ENV=production
- DATABASE_URL=postgres://user:password@db:5432/myapp
- REDIS_URL=redis://redis:6379/0
depends_on:
- db
- redis
restart: unless-stopped
deploy:
replicas: 2
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
- app
restart: unless-stopped
db:
image: postgres:15-alpine
environment:
- POSTGRES_DB=myapp
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
restart: unless-stopped
volumes:
postgres_data:
redis_data:# nginx.conf
events {
worker_connections 1024;
}
http {
upstream app {
server app:8080;
}
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
location / {
proxy_pass http://app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD wget -q --spider http://localhost:8080/health || exit 1# Docker Hub
docker tag myapp:latest username/myapp:latest
docker push username/myapp:latest
# GitHub Container Registry
docker tag myapp:latest ghcr.io/username/myapp:latest
docker push ghcr.io/username/myapp:latest
# AWS ECR
aws ecr get-login-password | docker login --username AWS --password-stdin 123456789.dkr.ecr.us-east-1.amazonaws.com
docker tag myapp:latest 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest
docker push 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latestname: Build and Deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Push to registry
run: |
echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
docker push myapp:${{ github.sha }}
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Deploy to server
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
docker pull myapp:${{ github.sha }}
docker-compose up -dclass ValidationError < Azu::Response::Error
getter errors : Array(FieldError)
def initialize(@errors : Array(FieldError))
super("Validation failed", 422)
end
def to_json(io : IO)
{
error: message,
details: errors.map { |e| {field: e.field, message: e.message} }
}.to_json(io)
end
end
record FieldError, field : String, message : String# Payment errors
class PaymentError < Azu::Response::Error
def initialize(message : String)
super(message, 402) # Payment Required
end
end
class InsufficientFundsError < PaymentError
def initialize(required : Float64, available : Float64)
super("Insufficient funds: need $#{required}, have $#{available}")
end
end
class PaymentDeclinedError < PaymentError
getter decline_code : String
def initialize(@decline_code : String)
super("Payment declined: #{decline_code}")
end
end
# Inventory errors
class InventoryError < Azu::Response::Error
def initialize(message : String)
super(message, 422)
end
end
class OutOfStockError < InventoryError
def initialize(product_name : String)
super("#{product_name} is out of stock")
end
endmodule Errors
# Base application error
abstract class AppError < Azu::Response::Error
getter code : String
def initialize(message : String, status : Int32, @code : String)
super(message, status)
end
def to_json(io : IO)
{
error: {
code: code,
message: message
}
}.to_json(io)
end
end
# Client errors (4xx)
class BadRequest < AppError
def initialize(message : String, code = "BAD_REQUEST")
super(message, 400, code)
end
end
class Unauthorized < AppError
def initialize(message = "Authentication required")
super(message, 401, "UNAUTHORIZED")
end
end
class Forbidden < AppError
def initialize(message = "Access denied")
super(message, 403, "FORBIDDEN")
end
end
class NotFound < AppError
def initialize(resource : String)
super("#{resource} not found", 404, "NOT_FOUND")
end
end
class Conflict < AppError
def initialize(message : String)
super(message, 409, "CONFLICT")
end
end
class ValidationFailed < AppError
getter details : Array(Hash(String, String))
def initialize(@details : Array(Hash(String, String)))
super("Validation failed", 422, "VALIDATION_FAILED")
end
def to_json(io : IO)
{
error: {
code: code,
message: message,
details: details
}
}.to_json(io)
end
end
# Server errors (5xx)
class InternalError < AppError
def initialize(message = "Internal server error")
super(message, 500, "INTERNAL_ERROR")
end
end
class ServiceUnavailable < AppError
def initialize(service : String)
super("#{service} is temporarily unavailable", 503, "SERVICE_UNAVAILABLE")
end
end
endclass ApiError < Azu::Response::Error
getter code : String
getter details : Hash(String, JSON::Any)?
getter request_id : String?
def initialize(
message : String,
status : Int32,
@code : String,
@details : Hash(String, JSON::Any)? = nil,
@request_id : String? = nil
)
super(message, status)
end
def to_json(io : IO)
response = {
error: {
code: code,
message: message,
timestamp: Time.utc.to_rfc3339
}
}
response[:error][:details] = details if details
response[:error][:request_id] = request_id if request_id
response.to_json(io)
end
endclass CustomErrorHandler < Azu::Handler::Base
def call(context)
call_next(context)
rescue ex : Errors::AppError
context.response.status_code = ex.status
context.response.content_type = "application/json"
ex.to_json(context.response)
rescue ex : Azu::Response::Error
context.response.status_code = ex.status
context.response.content_type = "application/json"
{error: ex.message}.to_json(context.response)
end
endstruct TransferFundsEndpoint
include Azu::Endpoint(TransferRequest, TransferResponse)
post "/transfers"
def call : TransferResponse
from_account = Account.find?(transfer_request.from_account_id)
raise Errors::NotFound.new("Source account") unless from_account
to_account = Account.find?(transfer_request.to_account_id)
raise Errors::NotFound.new("Destination account") unless to_account
amount = transfer_request.amount
raise Errors::BadRequest.new("Amount must be positive") if amount <= 0
if from_account.balance < amount
raise InsufficientFundsError.new(amount, from_account.balance)
end
transfer = Transfer.execute!(from_account, to_account, amount)
TransferResponse.new(transfer)
end
end# Each error code has a specific meaning:
#
# Client Errors:
# - BAD_REQUEST (400): The request was malformed
# - UNAUTHORIZED (401): Authentication is required
# - FORBIDDEN (403): You don't have permission
# - NOT_FOUND (404): The resource doesn't exist
# - VALIDATION_FAILED (422): Input validation failed
#
# Server Errors:
# - INTERNAL_ERROR (500): Something went wrong on our end
# - SERVICE_UNAVAILABLE (503): A dependent service is downclass SessionStore
def self.create(user_id : Int64) : String
session_id = Random::Secure.hex(32)
Azu.cache.set(
"session:#{session_id}",
{user_id: user_id, created_at: Time.utc}.to_json,
expires_in: 24.hours
)
session_id
end
def self.get(session_id : String) : Int64?
data = Azu.cache.get("session:#{session_id}")
return nil unless data
JSON.parse(data)["user_id"].as_i64
end
endupstream app_servers {
least_conn; # Use least connections algorithm
server app1:8080;
server app2:8080;
server app3:8080;
}
server {
listen 80;
location / {
proxy_pass http://app_servers;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /health {
proxy_pass http://app_servers;
proxy_connect_timeout 1s;
proxy_read_timeout 1s;
}
}frontend http_front
bind *:80
default_backend app_servers
backend app_servers
balance roundrobin
option httpchk GET /health
http-check expect status 200
server app1 app1:8080 check inter 5s fall 3 rise 2
server app2 app2:8080 check inter 5s fall 3 rise 2
server app3 app3:8080 check inter 5s fall 3 rise 2# docker-compose.swarm.yml
version: "3.8"
services:
app:
image: myapp:latest
deploy:
replicas: 3
update_config:
parallelism: 1
delay: 10s
restart_policy:
condition: on-failure
resources:
limits:
cpus: "0.5"
memory: 512M
environment:
- AZU_ENV=production
- DATABASE_URL=postgres://...
- REDIS_URL=redis://redis:6379/0
networks:
- app_network
nginx:
image: nginx:alpine
ports:
- "80:80"
deploy:
placement:
constraints:
- node.role == manager
networks:
- app_network
redis:
image: redis:7-alpine
deploy:
replicas: 1
networks:
- app_network
networks:
app_network:
driver: overlaydocker stack deploy -c docker-compose.swarm.yml myapp
docker service scale myapp_app=5# kubernetes/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 3
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: app
image: myapp:latest
ports:
- containerPort: 8080
env:
- name: AZU_ENV
value: "production"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: myapp-secrets
key: database-url
resources:
limits:
cpu: "500m"
memory: "512Mi"
requests:
cpu: "250m"
memory: "256Mi"
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: myapp
spec:
selector:
app: myapp
ports:
- port: 80
targetPort: 8080
type: LoadBalancer
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: myapp-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: myapp
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70class ScalableNotificationChannel < Azu::Channel
PATH = "/notifications"
@@redis = Redis.new(url: ENV["REDIS_URL"])
@@local_connections = [] of HTTP::WebSocket
def on_connect
@@local_connections << socket
# Subscribe to Redis channel
spawn do
@@redis.subscribe("notifications") do |on|
on.message do |channel, message|
@@local_connections.each(&.send(message))
end
end
end
end
def self.broadcast(message : String)
# Publish to Redis - all servers receive it
@@redis.publish("notifications", message)
end
endmodule Database
PRIMARY = CQL::Schema.define(:primary,
adapter: CQL::Adapter::Postgres,
uri: ENV["DATABASE_URL"]
)
REPLICA = CQL::Schema.define(:replica,
adapter: CQL::Adapter::Postgres,
uri: ENV["DATABASE_REPLICA_URL"]
)
def self.read
REPLICA
end
def self.write
PRIMARY
end
end
# Usage
users = Database.read.query("SELECT * FROM users")
Database.write.exec("INSERT INTO users ...")# pgbouncer.ini
[databases]
myapp = host=db port=5432 dbname=myapp
[pgbouncer]
listen_port = 6432
listen_addr = 0.0.0.0
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20Azu.configure do |config|
config.cache = Azu::Cache::RedisStore.new(
url: ENV["REDIS_CLUSTER_URL"],
cluster: true
)
endstruct HealthEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Json)
get "/health"
def call
json({
status: "healthy",
instance: ENV.fetch("HOSTNAME", "unknown"),
version: ENV.fetch("APP_VERSION", "unknown"),
uptime: Process.times.real.to_i
})
end
end# spec/channels/notification_channel_spec.cr
require "../spec_helper"
require "../support/mock_websocket"
describe NotificationChannel do
describe "#on_connect" do
it "adds socket to connections" do
channel = NotificationChannel.new
socket = MockWebSocket.new
initial_count = NotificationChannel::CONNECTIONS.size
channel.socket = socket
channel.on_connect
NotificationChannel::CONNECTIONS.size.should eq(initial_count + 1)
end
it "sends welcome message" do
channel = NotificationChannel.new
socket = MockWebSocket.new
channel.socket = socket
channel.on_connect
socket.sent_messages.size.should eq(1)
message = JSON.parse(socket.sent_messages.first)
message["type"].should eq("connected")
end
end
enddescribe NotificationChannel do
describe "#on_message" do
it "responds to ping with pong" do
channel = NotificationChannel.new
socket = MockWebSocket.new
channel.socket = socket
channel.on_connect
channel.on_message(%({"type": "ping"}))
pong = socket.sent_messages.find do |m|
JSON.parse(m)["type"] == "pong"
end
pong.should_not be_nil
end
it "handles subscribe action" do
channel = NotificationChannel.new
socket = MockWebSocket.new
channel.socket = socket
channel.on_connect
channel.on_message(%({"type": "subscribe", "topic": "news"}))
response = socket.sent_messages.last
JSON.parse(response)["type"].should eq("subscribed")
end
it "handles invalid JSON gracefully" do
channel = NotificationChannel.new
socket = MockWebSocket.new
channel.socket = socket
channel.on_connect
channel.on_message("not valid json")
error_msg = socket.sent_messages.find do |m|
JSON.parse(m)["type"] == "error"
end
error_msg.should_not be_nil
end
end
enddescribe NotificationChannel do
describe "#on_close" do
it "removes socket from connections" do
channel = NotificationChannel.new
socket = MockWebSocket.new
channel.socket = socket
channel.on_connect
count_before = NotificationChannel::CONNECTIONS.size
channel.on_close(nil, nil)
NotificationChannel::CONNECTIONS.size.should eq(count_before - 1)
end
it "cleans up subscriptions" do
channel = NotificationChannel.new
socket = MockWebSocket.new
channel.socket = socket
channel.on_connect
# Subscribe to topic
channel.on_message(%({"type": "subscribe", "topic": "news"}))
channel.on_close(nil, nil)
# Verify subscription cleaned up
channel.subscriptions.should be_empty
end
end
enddescribe NotificationChannel do
describe ".broadcast" do
it "sends message to all connections" do
sockets = 3.times.map { MockWebSocket.new }.to_a
channels = sockets.map do |socket|
channel = NotificationChannel.new
channel.socket = socket
channel.on_connect
channel
end
NotificationChannel.broadcast("Hello everyone!")
sockets.each do |socket|
socket.sent_messages.should contain("Hello everyone!")
end
end
it "excludes sender when specified" do
sender_socket = MockWebSocket.new
other_socket = MockWebSocket.new
sender = NotificationChannel.new
sender.socket = sender_socket
sender.on_connect
other = NotificationChannel.new
other.socket = other_socket
other.on_connect
NotificationChannel.broadcast("Hello!", except: sender_socket)
sender_socket.sent_messages.should_not contain("Hello!")
other_socket.sent_messages.should contain("Hello!")
end
end
enddescribe RoomChannel do
describe "room management" do
it "joins user to room" do
channel = RoomChannel.new
socket = MockWebSocket.new
channel.socket = socket
channel.params = {"room_id" => "room-1"}
channel.on_connect
RoomChannel.room_members("room-1").should contain(socket)
end
it "broadcasts only to room members" do
room1_socket = MockWebSocket.new
room2_socket = MockWebSocket.new
channel1 = RoomChannel.new
channel1.socket = room1_socket
channel1.params = {"room_id" => "room-1"}
channel1.on_connect
channel2 = RoomChannel.new
channel2.socket = room2_socket
channel2.params = {"room_id" => "room-2"}
channel2.on_connect
RoomChannel.broadcast_to("room-1", "Room 1 only")
room1_socket.sent_messages.should contain("Room 1 only")
room2_socket.sent_messages.should_not contain("Room 1 only")
end
end
enddescribe AuthenticatedChannel do
describe "#on_connect" do
it "rejects invalid tokens" do
channel = AuthenticatedChannel.new
socket = MockWebSocket.new
context = create_ws_context(query: "token=invalid")
channel.socket = socket
channel.context = context
channel.on_connect
socket.closed.should be_true
error_msg = socket.sent_messages.find do |m|
JSON.parse(m)["type"] == "error"
end
error_msg.should_not be_nil
end
it "accepts valid tokens" do
user = User.create!(name: "Alice", email: "alice@example.com")
token = Token.create(user_id: user.id)
channel = AuthenticatedChannel.new
socket = MockWebSocket.new
context = create_ws_context(query: "token=#{token}")
channel.socket = socket
channel.context = context
channel.on_connect
socket.closed.should be_false
success_msg = socket.sent_messages.find do |m|
JSON.parse(m)["type"] == "authenticated"
end
success_msg.should_not be_nil
end
end
end
def create_ws_context(query : String = "") : HTTP::Server::Context
io = IO::Memory.new
request = HTTP::Request.new("GET", "/ws?#{query}")
response = HTTP::Server::Response.new(io)
HTTP::Server::Context.new(request, response)
endrequire "http/web_socket"
describe "WebSocket Integration" do
before_all do
spawn { MyApp.start }
sleep 1.second
end
it "establishes connection and receives messages" do
received = [] of String
done = Channel(Nil).new
ws = HTTP::WebSocket.new("ws://localhost:4000/notifications")
ws.on_message do |message|
received << message
if received.size >= 2
done.send(nil)
end
end
spawn { ws.run }
sleep 100.milliseconds
# Send a message
ws.send(%({"type": "ping"}))
# Wait for response
select
when done.receive
when timeout(5.seconds)
fail "Timeout waiting for messages"
end
# Should have welcome + pong
received.size.should be >= 2
ws.close
end
end# Application
AZU_ENV=production
PORT=8080
HOST=0.0.0.0
# Database
DATABASE_URL=postgres://user:password@localhost:5432/myapp_prod
# Cache
REDIS_URL=redis://localhost:6379/0
# Security
SECRET_KEY=your-secret-key-here
JWT_SECRET=your-jwt-secret
# External Services
SMTP_HOST=smtp.example.com
SMTP_PORT=587AZU_ENV=production
PORT=8080
DATABASE_URL=postgres://user:actualpassword@db.example.com:5432/myapp_prod
REDIS_URL=redis://redis.example.com:6379/0
SECRET_KEY=actual-production-secret# Build with release optimizations
crystal build --release --no-debug src/app.cr -o bin/app
# Static linking (Alpine/Docker)
crystal build --release --static --no-debug src/app.cr -o bin/appAzu.configure do |config|
if ENV["SSL_CERT"]? && ENV["SSL_KEY"]?
config.ssl = {
cert: ENV["SSL_CERT"],
key: ENV["SSL_KEY"]
}
end
endclass SecurityHeaders < Azu::Handler::Base
def call(context)
headers = context.response.headers
# HTTPS enforcement
headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
# XSS protection
headers["X-Content-Type-Options"] = "nosniff"
headers["X-Frame-Options"] = "DENY"
headers["X-XSS-Protection"] = "1; mode=block"
# CSP (customize as needed)
headers["Content-Security-Policy"] = "default-src 'self'"
call_next(context)
end
endLog.setup do |config|
if ENV["AZU_ENV"] == "production"
# JSON logs for production
backend = JsonLogBackend.new(STDOUT)
config.bind "*", :info, backend
else
# Human-readable for development
config.bind "*", :debug, Log::IOBackend.new
end
endstruct HealthEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Json)
get "/health"
def call
checks = {
database: check_database,
redis: check_redis,
}
all_healthy = checks.values.all?
status(all_healthy ? 200 : 503)
json({
status: all_healthy ? "healthy" : "unhealthy",
checks: checks,
timestamp: Time.utc.to_rfc3339,
version: ENV.fetch("APP_VERSION", "unknown")
})
end
private def check_database : Bool
AcmeDB.exec("SELECT 1")
true
rescue
false
end
private def check_redis : Bool
Azu.cache.set("health", "ok", expires_in: 1.second)
true
rescue
false
end
endmodule MyApp
@@running = true
def self.start
server = HTTP::Server.new(handlers)
# Handle shutdown signals
Signal::INT.trap { shutdown(server) }
Signal::TERM.trap { shutdown(server) }
Log.info { "Starting server on port #{port}" }
server.listen(host, port)
end
private def self.shutdown(server)
return unless @@running
@@running = false
Log.info { "Shutting down gracefully..." }
# Stop accepting new connections
server.close
# Wait for in-flight requests
sleep 5.seconds
Log.info { "Shutdown complete" }
exit 0
end
endAcmeDB = CQL::Schema.define(:acme_db,
adapter: CQL::Adapter::Postgres,
uri: ENV["DATABASE_URL"],
pool_size: ENV.fetch("DB_POOL_SIZE", "10").to_i,
checkout_timeout: 5.seconds
)class RateLimiter < Azu::Handler::Base
LIMIT = 100
WINDOW = 1.minute
def call(context)
key = "ratelimit:#{client_ip(context)}"
current = Azu.cache.increment(key)
Azu.cache.expire(key, WINDOW) if current == 1
context.response.headers["X-RateLimit-Limit"] = LIMIT.to_s
context.response.headers["X-RateLimit-Remaining"] = Math.max(0, LIMIT - current).to_s
if current > LIMIT
context.response.status_code = 429
context.response.print({error: "Too many requests"}.to_json)
return
end
call_next(context)
end
enddef content : String
<<-HTML
<div id="my-component">
<p>Count: #{@count}</p>
</div>
HTML
enddef mount(socket)
@socket = socket
load_initial_data
push_state
enddef unmount
cleanup_subscriptions
save_state
endon_event "increment" do
@count += 1
push_state # Sends updated HTML to client
enddef add_item(item)
@items << item
push_append("#items-list", render_item(item))
enddef add_notification(notification)
push_prepend("#notifications", render_notification(notification))
enddef update_status(status)
push_replace("#status", "<span>#{status}</span>")
enddef remove_item(id)
@items.reject! { |i| i.id == id }
push_remove("#item-#{id}")
endon_event "click_button" do
handle_button_click
end
on_event "submit_form" do |data|
name = data["name"].as_s
process_form(name)
end<button azu-click="click_button">Click Me</button>
<button azu-click="delete" azu-value="123">Delete</button>
<input azu-change="input_changed" azu-model="name">
<form azu-submit="submit_form">...</form>class UserComponent
include Azu::Component
property user_id : Int64
property show_details : Bool = false
def mount(socket)
@user = User.find(user_id)
push_state
end
end<div azu-component="UserComponent" azu-props='{"user_id": 123, "show_details": true}'></div>class CounterComponent
include Azu::Component
@count = 0
@history = [] of Int32
def content
<<-HTML
<div>
<p>Count: #{@count}</p>
<button azu-click="increment">+</button>
<button azu-click="decrement">-</button>
<button azu-click="reset">Reset</button>
</div>
HTML
end
on_event "increment" do
@history << @count
@count += 1
push_state
end
on_event "decrement" do
@history << @count
@count -= 1
push_state
end
on_event "reset" do
@history.clear
@count = 0
push_state
end
endAzu::Spark.register(MyComponent)
Azu::Spark.register(CounterComponent)
Azu::Spark.register(UserComponent)<script src="/azu/spark.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
Spark.connect('/spark');
});
</script><div azu-component="CounterComponent"></div>
<div azu-component="UserComponent" azu-props='{"user_id": 42}'></div>class TodoComponent
include Azu::Component
@todos = [] of Todo
@new_todo = ""
def mount(socket)
@todos = Todo.all
push_state
end
def content
<<-HTML
<div class="todo-app">
<h1>Todos (#{@todos.size})</h1>
<form azu-submit="add_todo">
<input type="text"
azu-model="new_todo"
value="#{@new_todo}"
placeholder="What needs to be done?">
<button type="submit">Add</button>
</form>
<ul id="todo-list">
#{@todos.map { |t| render_todo(t) }.join}
</ul>
</div>
HTML
end
private def render_todo(todo : Todo)
<<-HTML
<li id="todo-#{todo.id}" class="#{todo.completed? ? "completed" : ""}">
<input type="checkbox"
azu-change="toggle"
azu-value="#{todo.id}"
#{todo.completed? ? "checked" : ""}>
<span>#{todo.title}</span>
<button azu-click="delete" azu-value="#{todo.id}">Γ</button>
</li>
HTML
end
on_event "new_todo_change" do |value|
@new_todo = value.as_s
end
on_event "add_todo" do
unless @new_todo.empty?
todo = Todo.create!(title: @new_todo)
@todos << todo
@new_todo = ""
push_state
end
end
on_event "toggle" do |id|
if todo = @todos.find { |t| t.id == id.as_i64 }
todo.toggle!
push_replace("#todo-#{todo.id}", render_todo(todo))
end
end
on_event "delete" do |id|
@todos.reject! { |t| t.id == id.as_i64 }
push_remove("#todo-#{id}")
end
endPATH = "/notifications"
PATH = "/chat/:room_id"def on_connect
# Initialize connection
# Authenticate user
# Join rooms
enddef on_message(message : String)
data = JSON.parse(message)
# Process message
enddef on_close(code : Int32?, reason : String?)
# Cleanup resources
# Remove from rooms
enddef on_error(error : Exception)
Log.error { "WebSocket error: #{error.message}" }
enddef on_connect
socket.object_id # Unique identifier
enddef on_connect
send({type: "welcome", message: "Hello!"}.to_json)
enddef on_message(message : String)
if invalid_message?(message)
close(code: 4000, reason: "Invalid message")
end
enddef on_connect
context.request.query_params["token"]?
end# PATH = "/rooms/:room_id"
def on_connect
room_id = params["room_id"]
enddef on_connect
auth = headers["Authorization"]?
endclass NotificationChannel < Azu::Channel
CONNECTIONS = [] of HTTP::WebSocket
def on_connect
CONNECTIONS << socket
end
def on_close(code, reason)
CONNECTIONS.delete(socket)
end
def self.broadcast(message : String)
CONNECTIONS.each(&.send(message))
end
enddef broadcast(message : String, except : HTTP::WebSocket? = nil)
CONNECTIONS.each do |ws|
ws.send(message) unless ws == except
end
endclass RoomChannel < Azu::Channel
PATH = "/rooms/:room_id"
@@rooms = Hash(String, Set(HTTP::WebSocket)).new { |h, k| h[k] = Set(HTTP::WebSocket).new }
def on_connect
room_id = params["room_id"]
@@rooms[room_id] << socket
end
def on_close(code, reason)
room_id = params["room_id"]
@@rooms[room_id].delete(socket)
end
def self.broadcast_to(room_id : String, message : String)
@@rooms[room_id].each(&.send(message))
end
def self.room_count(room_id : String) : Int32
@@rooms[room_id].size
end
endclass SecureChannel < Azu::Channel
PATH = "/secure"
@user : User?
def on_connect
token = context.request.query_params["token"]?
unless token && (@user = authenticate(token))
send({type: "error", message: "Unauthorized"}.to_json)
close(code: 4001, reason: "Unauthorized")
return
end
send({type: "authenticated", user: @user.not_nil!.name}.to_json)
end
private def authenticate(token : String) : User?
Token.validate(token)
end
enddef on_message(message : String)
data = JSON.parse(message)
case data["type"]?.try(&.as_s)
when "ping"
handle_ping
when "subscribe"
handle_subscribe(data)
when "message"
handle_message(data)
else
send({type: "error", message: "Unknown type"}.to_json)
end
rescue JSON::ParseException
send({type: "error", message: "Invalid JSON"}.to_json)
end
private def handle_ping
send({type: "pong", timestamp: Time.utc.to_unix}.to_json)
endclass ChatChannel < Azu::Channel
PATH = "/chat/:room"
@@rooms = Hash(String, Array(HTTP::WebSocket)).new { |h, k| h[k] = [] of HTTP::WebSocket }
def on_connect
room = params["room"]
@@rooms[room] << socket
broadcast_to_room(room, {
type: "system",
message: "User joined",
users: @@rooms[room].size
}.to_json)
end
def on_message(message : String)
room = params["room"]
broadcast_to_room(room, message, except: socket)
end
def on_close(code, reason)
room = params["room"]
@@rooms[room].delete(socket)
broadcast_to_room(room, {
type: "system",
message: "User left",
users: @@rooms[room].size
}.to_json)
end
private def broadcast_to_room(room : String, message : String, except : HTTP::WebSocket? = nil)
@@rooms[room].each do |ws|
ws.send(message) unless ws == except
end
end
endvalidate field_name, rule: value, ...validate name, presence: truevalidate name, length: {min: 2} # At least 2 chars
validate name, length: {max: 100} # At most 100 chars
validate name, length: {min: 2, max: 100} # Between 2 and 100
validate code, length: {is: 6} # Exactly 6 charsvalidate email, format: /@/
validate phone, format: /^\d{10}$/
validate slug, format: /^[a-z0-9-]+$/validate age, numericality: {greater_than: 0}
validate age, numericality: {greater_than_or_equal_to: 18}
validate age, numericality: {less_than: 150}
validate age, numericality: {less_than_or_equal_to: 120}
validate quantity, numericality: {equal_to: 1}validate status, inclusion: {in: ["pending", "active", "archived"]}
validate role, inclusion: {in: Role.values.map(&.to_s)}validate username, exclusion: {in: ["admin", "root", "system"]}request = MyRequest.new(name: "")
request.valid? # => falserequest.errors # => Array(Error)
request.errors.each do |error|
puts "#{error.field}: #{error.message}"
enddef validate
super # Run standard validations
if custom_condition_fails
errors << Error.new(:field, "custom message")
end
endError.new(field : Symbol, message : String)struct GetUserEndpoint
include Azu::Endpoint(EmptyRequest, UserResponse)
get "/users/:id"
def call : UserResponse
# No request body to parse
end
endstruct UploadRequest
include Azu::Request
getter file : HTTP::FormData::File
getter description : String?
def initialize(@file, @description = nil)
end
endstruct CreateUserRequest
include Azu::Request
getter name : String
getter email : String
getter password : String
getter age : Int32?
getter role : String
def initialize(
@name = "",
@email = "",
@password = "",
@age = nil,
@role = "user"
)
end
validate name, presence: true, length: {min: 2, max: 100}
validate email, presence: true, format: /@/
validate password, presence: true, length: {min: 8}
validate age, numericality: {greater_than: 0, less_than: 150}, allow_nil: true
validate role, inclusion: {in: ["user", "admin", "moderator"]}
def validate
super
if email_taken?(email)
errors << Error.new(:email, "is already taken")
end
end
private def email_taken?(email : String) : Bool
User.exists?(email: email)
end
endget "/"
get "/users"
get "/api/v1/health"get "/users/:id" # Single parameter
get "/posts/:id/comments" # Parameter in middle
get "/:category/:slug" # Multiple parametersget "/files/*path" # Captures rest of pathget "/users" # Matches /users exactly
get "/users/:id" # Matches /users/123
get "/users/*rest" # Matches /users/123/posts/456get "/users/:id/posts/:post_id"
def call
user_id = params["id"] # => "123"
post_id = params["post_id"] # => "456"
endget "/files/*path"
def call
path = params["path"] # => "docs/readme.md"
end# Only match numeric IDs
get "/users/:id" do
constraint :id, /^\d+$/
endAzu.configure do |config|
config.router.path_cache_size = 1000 # Cache 1000 paths
endAzu.router.routes.each do |route|
puts "#{route.method} #{route.path}"
endroute = Azu.router.find("GET", "/users/123")
route.handler # => Handler
route.params # => {"id" => "123"}# Define multiple endpoints with shared prefix
struct ApiV1::UsersIndex
include Azu::Endpoint(EmptyRequest, UsersResponse)
get "/api/v1/users"
end
struct ApiV1::UsersShow
include Azu::Endpoint(EmptyRequest, UserResponse)
get "/api/v1/users/:id"
end# Handler::Rescuer returns 404
{
"error": "Not Found",
"path": "/unknown"
}# Returns 405 with Allow header
{
"error": "Method Not Allowed",
"allowed": ["GET", "POST"]
}# User CRUD endpoints
struct UsersIndex
include Azu::Endpoint(EmptyRequest, UsersResponse)
get "/users"
end
struct UsersShow
include Azu::Endpoint(EmptyRequest, UserResponse)
get "/users/:id"
end
struct UsersCreate
include Azu::Endpoint(CreateUserRequest, UserResponse)
post "/users"
end
struct UsersUpdate
include Azu::Endpoint(UpdateUserRequest, UserResponse)
put "/users/:id"
end
struct UsersDelete
include Azu::Endpoint(EmptyRequest, Azu::Response::Empty)
delete "/users/:id"
end
# Nested routes
struct UserPosts
include Azu::Endpoint(EmptyRequest, PostsResponse)
get "/users/:user_id/posts"
end
struct UserPostShow
include Azu::Endpoint(EmptyRequest, PostResponse)
get "/users/:user_id/posts/:id"
end# In migration
def up
create_index :users, :email, unique: true
create_index :posts, :user_id
create_index :posts, [:user_id, :created_at]
create_index :orders, :status
end# PostgreSQL
AcmeDB.query("EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com'")
# Look for:
# - Sequential Scan (bad for large tables)
# - Index Scan (good)
# - Index Only Scan (best)# Bad: N+1 queries
posts = Post.all
posts.each do |post|
puts post.author.name # One query per post!
end# Good: Eager loading
posts = Post.includes(:author).all
posts.each do |post|
puts post.author.name # No additional queries
end# Load posts with authors in one query
posts = Post
.join(:users, "users.id = posts.user_id")
.select("posts.*, users.name as author_name")
.all# Bad: Loads all columns
users = User.all
# Good: Loads only needed columns
users = User.select(:id, :name, :email).all
# For large text/blob columns especially
posts = Post.select(:id, :title, :created_at).all # Skip body column# Bad: Loads all records into memory
User.all.each do |user|
process(user)
end
# Good: Process in batches
User.find_each(batch_size: 1000) do |user|
process(user)
end
# Or with explicit batching
User.in_batches(of: 1000) do |batch|
batch.each { |user| process(user) }
end# Bad: Loads all records to count
users = User.where(active: true).all
count = users.size
# Good: Count in database
count = User.where(active: true).count# Bad: Counts all matching records
has_orders = Order.where(user_id: user.id).count > 0
# Good: Stops at first match
has_orders = Order.where(user_id: user.id).exists?# Bad: No limit
recent_posts = Post.order(created_at: :desc).all
# Good: Always limit
recent_posts = Post.order(created_at: :desc).limit(10).all# Bad: Ruby iteration
users = User.all
total = users.sum(&.balance)
# Good: Database aggregation
total = User.sum(:balance)
# Other aggregations
average = User.average(:age)
max_price = Product.maximum(:price)
min_date = Order.minimum(:created_at)# Bad: Individual updates
users.each do |user|
user.update!(last_notified: Time.utc)
end
# Good: Single update query
User.where(id: user_ids).update_all(last_notified: Time.utc)# Bad: Individual inserts
records.each do |data|
User.create!(data)
end
# Good: Bulk insert
User.insert_all(records)# CQL uses prepared statements by default
User.where(email: email).firstAcmeDB = CQL::Schema.define(:acme_db,
adapter: CQL::Adapter::Postgres,
uri: ENV["DATABASE_URL"],
pool_size: ENV.fetch("DB_POOL_SIZE", "20").to_i,
checkout_timeout: 5.seconds
)def expensive_stats
cache_key = "stats:#{Date.today}"
Azu.cache.fetch(cache_key, expires_in: 1.hour) do
{
total_users: User.count,
active_users: User.where(active: true).count,
total_orders: Order.count,
revenue: Order.sum(:total)
}.to_json
end
endmodule DB
PRIMARY = connect(ENV["DATABASE_URL"])
REPLICA = connect(ENV["DATABASE_REPLICA_URL"])
def self.read
REPLICA
end
def self.write
PRIMARY
end
end
# Usage
users = DB.read { User.all }
DB.write { user.save! }# Bad: OFFSET with large values
User.offset(10000).limit(20).all # Scans 10,020 rows
# Good: Cursor-based pagination
last_id = params["after_id"]?.try(&.to_i64) || 0
User.where("id > ?", last_id).limit(20).order(id: :asc).all# Bad: LIKE with leading wildcard
User.where("name LIKE ?", "%smith%") # Can't use index
# Good: Full-text search or trigram
User.where("name ILIKE ?", "smith%") # Can use index
# Better: Full-text search
User.where("to_tsvector('english', name) @@ plainto_tsquery('english', ?)", search_term)# Bad: Function on indexed column
Order.where("DATE(created_at) = ?", date) # Can't use index
# Good: Range query
Order.where("created_at >= ? AND created_at < ?", date.at_beginning_of_day, date.tomorrow.at_beginning_of_day)class QueryLogger
@@slow_threshold = 100.milliseconds
def self.log(sql : String, duration : Time::Span)
if duration > @@slow_threshold
Log.warn { "Slow query (#{duration.total_milliseconds}ms): #{sql}" }
end
end
endstruct ProductsEndpoint
include Azu::Endpoint(EmptyRequest, ProductsResponse)
get "/products"
CACHE_TTL = 5.minutes
def call : ProductsResponse
cache_key = "products:#{cache_params}"
cached = Azu.cache.get(cache_key)
return ProductsResponse.from_json(cached) if cached
products = Product.all
response = ProductsResponse.new(products)
Azu.cache.set(cache_key, response.to_json, expires_in: CACHE_TTL)
response
end
private def cache_params
"page=#{params["page"]? || 1}&limit=#{params["limit"]? || 20}"
end
enddef call
product = Product.find(params["id"])
# Set cache headers
context.response.headers["Cache-Control"] = "public, max-age=3600"
context.response.headers["ETag"] = generate_etag(product)
# Check If-None-Match
if_none_match = context.request.headers["If-None-Match"]?
if if_none_match == context.response.headers["ETag"]
status 304
return EmptyResponse.new
end
ProductResponse.new(product)
end
private def generate_etag(product)
%("#{product.updated_at.to_unix}")
endstruct UsersEndpoint
include Azu::Endpoint(EmptyRequest, UsersResponse)
get "/users"
DEFAULT_LIMIT = 20
MAX_LIMIT = 100
def call : UsersResponse
page = (params["page"]? || "1").to_i
limit = [(params["limit"]? || DEFAULT_LIMIT.to_s).to_i, MAX_LIMIT].min
offset = (page - 1) * limit
users = User.limit(limit).offset(offset).all
total = User.count
UsersResponse.new(
users: users,
page: page,
limit: limit,
total: total,
total_pages: (total / limit.to_f).ceil.to_i
)
end
enddef call
fields = params["fields"]?.try(&.split(",")) || ["id", "name", "email"]
users = User.select(fields.join(", ")).all
UsersResponse.new(users, fields)
end# Bad: N+1 queries
def call
posts = Post.all
posts.each do |post|
post.author # Each access triggers a query
end
end
# Good: Eager load
def call
posts = Post.includes(:author).all
posts.each do |post|
post.author # No additional query
end
enddef call
user_id = params["id"].to_i64
# Run queries in parallel
user_channel = Channel(User?).new
posts_channel = Channel(Array(Post)).new
stats_channel = Channel(UserStats).new
spawn { user_channel.send(User.find?(user_id)) }
spawn { posts_channel.send(Post.where(user_id: user_id).recent.limit(10).all) }
spawn { stats_channel.send(UserStats.for(user_id)) }
user = user_channel.receive
raise Azu::Response::NotFound.new("/users/#{user_id}") unless user
UserDetailResponse.new(
user: user,
posts: posts_channel.receive,
stats: stats_channel.receive
)
endclass CompressionHandler < Azu::Handler::Base
MIN_SIZE = 1024 # Only compress > 1KB
def call(context)
call_next(context)
return unless should_compress?(context)
body = context.response.output.to_s
return if body.bytesize < MIN_SIZE
compressed = Compress::Gzip.compress(body)
if compressed.bytesize < body.bytesize
context.response.headers["Content-Encoding"] = "gzip"
context.response.output = IO::Memory.new(compressed)
end
end
private def should_compress?(context) : Bool
accept = context.request.headers["Accept-Encoding"]?
return false unless accept
accept.includes?("gzip")
end
endcontext.response.headers["Connection"] = "keep-alive"
context.response.headers["Keep-Alive"] = "timeout=5, max=100"def call
context.response.content_type = "application/json"
context.response.headers["Transfer-Encoding"] = "chunked"
context.response.print "["
User.find_each(batch_size: 100) do |user, index|
context.response.print "," if index > 0
context.response.print user.to_json
context.response.flush
end
context.response.print "]"
enddef call
# Fire and forget for non-critical operations
spawn do
Analytics.track(
event: "page_view",
user_id: current_user_id,
path: context.request.path
)
end
# Return immediately
MainResponse.new(data)
enddef call
result = with_timeout(5.seconds) do
external_api.fetch_data
end
DataResponse.new(result)
rescue Timeout::Error
raise Azu::Response::ServiceUnavailable.new("External service timeout")
end
private def with_timeout(duration : Time::Span, &)
channel = Channel(typeof(yield)).new
spawn do
channel.send(yield)
end
select
when result = channel.receive
result
when timeout(duration)
raise Timeout::Error.new
end
endclass BenchmarkHandler < Azu::Handler::Base
def call(context)
start = Time.instant
start_gc = GC.stats
call_next(context)
duration = Time.instant - start
end_gc = GC.stats
allocated = end_gc.heap_size - start_gc.heap_size
context.response.headers["X-Response-Time"] = "#{duration.total_milliseconds.round(2)}ms"
context.response.headers["X-Memory-Allocated"] = "#{allocated / 1024}KB"
end
endusers = User.all # All users
users = User.where(active: true).all # Filtereduser = User.first # First by primary key
user = User.order(name: :asc).first # First alphabetically
user = User.where(active: true).first # First activeuser = User.last
user = User.order(created_at: :asc).lastuser = User.find(1) # Raises if not found
user = User.find?(1) # Returns nil if not founduser = User.find_by(email: "alice@example.com")
user = User.find_by?(email: "alice@example.com")users = User.take(5) # First 5 records# Hash conditions
User.where(active: true)
User.where(role: "admin", active: true)
# SQL conditions
User.where("age > ?", 18)
User.where("created_at > ?", 1.week.ago)
User.where("name LIKE ?", "%smith%")
# IN clause
User.where("id IN (?)", [1, 2, 3])
# NULL check
User.where("deleted_at IS NULL")User.where.not(role: "admin")
User.where.not("status IN (?)", ["banned", "suspended"])User.where(role: "admin").or(User.where(role: "moderator"))User.order(name: :asc)
User.order(created_at: :desc)
User.order(role: :asc, name: :asc) # Multiple columns
User.order("LOWER(name) ASC") # Raw SQLUser.order(name: :asc).reorder(created_at: :desc)User.order(name: :asc).reverse_order # Now descUser.limit(10)
User.where(active: true).limit(5)User.offset(20)
User.limit(10).offset(20) # Page 3User.select(:id, :name)
User.select("id, name, email")
User.select("*, LENGTH(bio) as bio_length")User.select(:role).distinctemails = User.pluck(:email) # => ["a@b.com", "c@d.com"]
data = User.pluck(:id, :name) # => [[1, "Alice"], [2, "Bob"]]user_ids = User.where(active: true).ids # => [1, 2, 3]User.count # Total users
User.where(active: true).count # Active users
User.count(:email) # Non-null emails
User.distinct.count(:role) # Unique rolesOrder.sum(:total)
Order.where(user_id: 1).sum(:total)User.average(:age)
Product.average(:price)Product.minimum(:price)
User.minimum(:created_at)Product.maximum(:price)
User.maximum(:age)User.select("role, COUNT(*) as count").group(:role)
Order.select("user_id, SUM(total) as total").group(:user_id)User.select("role, COUNT(*) as count")
.group(:role)
.having("COUNT(*) > ?", 5)Post.join(:users, "users.id = posts.user_id")
Post.join(:users, "users.id = posts.user_id")
.select("posts.*, users.name as author_name")User.left_join(:posts, "posts.user_id = users.id")
.select("users.*, COUNT(posts.id) as post_count")
.group("users.id")Post.includes(:author)
User.includes(:posts, :comments)User.where(email: "alice@example.com").exists?
User.exists?(email: "alice@example.com")User.where(role: "admin").any?User.where(role: "banned").none?User.where(active: false).empty?User.find_each(batch_size: 1000) do |user|
process(user)
endUser.in_batches(of: 1000) do |batch|
batch.update_all(notified: true)
endclass User
scope :active, -> { where(active: true) }
scope :recent, -> { order(created_at: :desc) }
scope :by_role, ->(role : String) { where(role: role) }
end
User.active.recent.all
User.by_role("admin").countUser.where(active: true)
.where("age >= ?", 18)
.order(name: :asc)
.limit(10)
.offset(20)
.allresults = MyDB.query("SELECT * FROM users WHERE age > ?", 18)MyDB.exec("UPDATE users SET active = ? WHERE id = ?", true, 1)Azu.configure do |config|
config.cache = Azu::Cache::MemoryStore.new
endAzu.configure do |config|
config.cache = Azu::Cache::RedisStore.new(
url: ENV["REDIS_URL"]
)
endAzu.cache.set("key", "value")
Azu.cache.set("key", "value", expires_in: 1.hour)value = Azu.cache.get("key") # => String?value = Azu.cache.fetch("key", expires_in: 1.hour) do
expensive_computation
endAzu.cache.delete("key")if Azu.cache.exists?("key")
# Key is cached
endAzu.cache.clearAzu.cache.increment("counter") # => 1
Azu.cache.increment("counter") # => 2
Azu.cache.increment("counter", by: 5) # => 7Azu.cache.decrement("counter")
Azu.cache.decrement("counter", by: 5)Azu.cache.expire("key", 30.minutes)Azu.cache.delete_matched("user:*")remaining = Azu.cache.ttl("key") # => Time::Span?def get_user(id : Int64) : User?
cache_key = "user:#{id}"
cached = Azu.cache.get(cache_key)
return User.from_json(cached) if cached
user = User.find?(id)
if user
Azu.cache.set(cache_key, user.to_json, expires_in: 15.minutes)
end
user
endclass User
after_save :update_cache
after_destroy :remove_from_cache
private def update_cache
Azu.cache.set("user:#{id}", to_json, expires_in: 1.hour)
end
private def remove_from_cache
Azu.cache.delete("user:#{id}")
end
enddef fetch_with_lock(key : String, expires_in : Time::Span, &)
# Try cache first
cached = Azu.cache.get(key)
return cached if cached
# Try to acquire lock
lock_key = "lock:#{key}"
if Azu.cache.set(lock_key, "1", expires_in: 10.seconds, nx: true)
begin
value = yield
Azu.cache.set(key, value, expires_in: expires_in)
value
ensure
Azu.cache.delete(lock_key)
end
else
# Wait and retry
sleep 100.milliseconds
Azu.cache.get(key) || yield
end
endAzu.configure do |config|
if ENV["AZU_ENV"] == "production"
config.cache = Azu::Cache::RedisStore.new(
url: ENV["REDIS_URL"],
pool_size: 10,
namespace: "myapp:production",
default_ttl: 1.hour
)
else
config.cache = Azu::Cache::MemoryStore.new(
max_size: 1000,
default_ttl: 15.minutes
)
end
endstruct UploadRequest
include Azu::Request
getter file : HTTP::FormData::File
getter description : String?
def initialize(@file, @description = nil)
end
end
struct UploadEndpoint
include Azu::Endpoint(UploadRequest, UploadResponse)
post "/upload"
def call : UploadResponse
file = upload_request.file
# Access file properties
filename = file.filename # Original filename
content = file.body # File content as IO
content_type = file.headers["Content-Type"]?
# Save the file
save_path = File.join("uploads", filename)
File.write(save_path, content.gets_to_end)
UploadResponse.new(filename, save_path)
end
end<form action="/upload" method="POST" enctype="multipart/form-data">
<input type="file" name="file" required>
<input type="text" name="description" placeholder="Description">
<button type="submit">Upload</button>
</form>struct MultiUploadRequest
include Azu::Request
getter files : Array(HTTP::FormData::File)
def initialize(@files = [] of HTTP::FormData::File)
end
end
struct MultiUploadEndpoint
include Azu::Endpoint(MultiUploadRequest, MultiUploadResponse)
post "/upload-multiple"
def call : MultiUploadResponse
saved_files = multi_upload_request.files.map do |file|
save_path = save_file(file)
{filename: file.filename, path: save_path}
end
MultiUploadResponse.new(saved_files)
end
private def save_file(file) : String
filename = generate_unique_filename(file.filename)
path = File.join("uploads", filename)
File.write(path, file.body.gets_to_end)
path
end
private def generate_unique_filename(original : String) : String
ext = File.extname(original)
"#{UUID.random}#{ext}"
end
endstruct UploadRequest
include Azu::Request
MAX_SIZE = 10 * 1024 * 1024 # 10 MB
getter file : HTTP::FormData::File
def initialize(@file)
end
def validate
super
if file.body.size > MAX_SIZE
errors << Error.new(:file, "must be smaller than 10 MB")
end
end
endmodule FileUploader
ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".pdf"]
UPLOAD_DIR = "uploads"
def self.save(file : HTTP::FormData::File) : String
# Sanitize filename
original = file.filename || "unnamed"
extension = File.extname(original).downcase
safe_name = "#{UUID.random}#{extension}"
# Validate extension
unless ALLOWED_EXTENSIONS.includes?(extension)
raise "Invalid file type"
end
# Ensure upload directory exists
Dir.mkdir_p(UPLOAD_DIR)
# Save file
path = File.join(UPLOAD_DIR, safe_name)
File.write(path, file.body.gets_to_end)
path
end
endstruct ImageUploadEndpoint
include Azu::Endpoint(ImageUploadRequest, ImageResponse)
post "/images"
def call : ImageResponse
file = image_upload_request.file
# Validate it's an image
unless image?(file)
raise Azu::Response::BadRequest.new("File must be an image")
end
# Save original
original_path = save_file(file, "originals")
# Create thumbnail (using external tool)
thumb_path = create_thumbnail(original_path)
ImageResponse.new(
original: original_path,
thumbnail: thumb_path
)
end
private def image?(file) : Bool
content_type = file.headers["Content-Type"]?
return false unless content_type
content_type.starts_with?("image/")
end
private def create_thumbnail(path : String) : String
thumb_path = path.gsub("originals", "thumbnails")
Dir.mkdir_p(File.dirname(thumb_path))
# Use ImageMagick or similar
Process.run("convert", [path, "-resize", "200x200", thumb_path])
thumb_path
end
endrequire "awscr-s3"
module S3Uploader
CLIENT = Awscr::S3::Client.new(
region: ENV["AWS_REGION"],
aws_access_key: ENV["AWS_ACCESS_KEY_ID"],
aws_secret_key: ENV["AWS_SECRET_ACCESS_KEY"]
)
BUCKET = ENV["S3_BUCKET"]
def self.upload(file : HTTP::FormData::File) : String
key = "uploads/#{UUID.random}/#{file.filename}"
CLIENT.put_object(
bucket: BUCKET,
object: key,
body: file.body.gets_to_end,
headers: {"Content-Type" => file.headers["Content-Type"]? || "application/octet-stream"}
)
"https://#{BUCKET}.s3.amazonaws.com/#{key}"
end
endconst form = document.getElementById('upload-form');
const progress = document.getElementById('progress');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = (e.loaded / e.total) * 100;
progress.style.width = percent + '%';
}
});
xhr.open('POST', '/upload');
xhr.send(formData);
});module FileCleanup
def self.cleanup_old_files(max_age : Time::Span = 7.days)
cutoff = Time.utc - max_age
Dir.glob("uploads/**/*").each do |path|
next if File.directory?(path)
if File.info(path).modification_time < cutoff
File.delete(path)
end
end
end
end{{ form_tag("/users", method="post") }}
{{ csrf_field() }}
{{ label_tag("user_name", "Name") }}
{{ text_field("user", "name", required=true) }}
{{ label_tag("user_email", "Email") }}
{{ email_field("user", "email", required=true) }}
{{ submit_button("Create User") }}
{{ end_form() }}{{ form_tag("/upload", method="post", multipart=true) }}
{{ csrf_field() }}
{{ label_tag("file", "Choose file") }}
<input type="file" name="file" id="file" accept="image/*">
{{ submit_button("Upload") }}
{{ end_form() }}{{ form_tag("/users", method="post") }}
{{ csrf_field() }}
<div class="form-group {% if errors['name'] %}has-error{% endif %}">
{{ label_tag("user_name", "Name") }}
{{ text_field("user", "name", value=form_data['name'], class="form-control") }}
{% if errors['name'] %}
<span class="error-message">{{ errors['name'] }}</span>
{% endif %}
</div>
{{ submit_button("Submit") }}
{{ end_form() }}{{ select_field("user", "role", options=[
{"value": "user", "label": "Regular User"},
{"value": "admin", "label": "Administrator"},
{"value": "moderator", "label": "Moderator"}
], selected=user.role, include_blank="Select a role...") }}{# Checkbox #}
{{ checkbox("user", "newsletter", label="Subscribe to newsletter") }}
{{ checkbox("user", "terms", required=true) }}
<label for="user_terms">I agree to the terms</label>
{# Radio buttons #}
{% for role in ["user", "admin", "moderator"] %}
{{ radio_button("user", "role", value=role, checked=(user.role == role)) }}
<label>{{ role | capitalize }}</label>
{% endfor %}<nav>
{{ link_to("Home", "/", class="nav-link " ~ ("/" | active_class("active"))) }}
{{ link_to("About", "/about", class="nav-link " ~ ("/about" | active_class("active"))) }}
{{ link_to("Contact", "/contact", class="nav-link " ~ ("/contact" | active_class("active"))) }}
</nav><nav>
{% for item in [
{"label": "Home", "path": "/"},
{"label": "Blog", "path": "/blog"},
{"label": "About", "path": "/about"}
] %}
<a href="{{ item.path }}"
class="nav-link {% if item.path | is_current_page %}active{% endif %}">
{{ item.label }}
</a>
{% endfor %}
</nav>{# External links automatically get rel="noopener noreferrer" #}
{{ link_to("Documentation", "https://docs.example.com", target="_blank") }}{# Simple delete #}
{{ button_to("Delete", "/posts/" ~ post.id, method="delete") }}
{# With confirmation #}
{{ button_to("Delete", "/posts/" ~ post.id,
method="delete",
confirm="Are you sure you want to delete this post?",
class="btn btn-danger") }}{# Link to collection (GET /users) #}
{{ link_to_get_users("View All Users", class="btn") }}
{# Link to member (GET /users/:id) #}
{{ link_to_get_user("View Profile", id=user.id) }}
{# Link to edit page #}
{{ link_to_get_edit_user("Edit", id=user.id, class="btn btn-secondary") }}{# Create form (POST /users) - method is clear from the name #}
{{ form_for_post_create_user(class="user-form") }}
{{ csrf_field() }}
{{ label_tag("user_name", "Name") }}
{{ text_field("user", "name", required=true) }}
{{ label_tag("user_email", "Email") }}
{{ email_field("user", "email", required=true) }}
{{ submit_button("Create User") }}
{{ end_form() }}
{# Edit form (PUT /users/:id) - _method field added automatically #}
{{ form_for_put_update_user(id=user.id, class="edit-form") }}
{{ csrf_field() }}
{{ label_tag("user_name", "Name") }}
{{ text_field("user", "name", value=user.name) }}
{{ submit_button("Update User") }}
{{ end_form() }}{# Simple delete button #}
{{ button_to_delete_delete_user(id=user.id) }}
{# With custom text and confirmation #}
{{ button_to_delete_delete_user(
text="Remove User",
id=user.id,
confirm="Are you sure you want to delete this user?",
class="btn btn-danger") }}{# User listing with all actions #}
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ link_to_get_user(user.name, id=user.id) }}</td>
<td>{{ user.email }}</td>
<td>
{{ link_to_get_edit_user("Edit", id=user.id, class="btn btn-sm") }}
{{ button_to_delete_delete_user(id=user.id, class="btn btn-sm btn-danger", confirm="Delete this user?") }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{# Link to create new user #}
{{ link_to_get_new_user("Add New User", class="btn btn-primary") }}<head>
<meta charset="UTF-8">
<title>{% block title %}My App{% endblock %}</title>
{{ favicon_tag("favicon.ico") }}
{{ stylesheet_tag("app.css") }}
{# Preload critical fonts #}
<link rel="preload" href="{{ 'fonts/inter.woff2' | asset_path }}"
as="font" type="font/woff2" crossorigin>
{{ csrf_meta() }}
</head><body>
{# ... content ... #}
{{ javascript_tag("vendor.js") }}
{{ javascript_tag("app.js", defer=true) }}
</body>{{ image_tag("hero.jpg",
alt="Welcome",
class="hero-image",
loading="lazy",
width=1200,
height=600) }}# locales/en.yml
en:
app_name: "My App"
welcome:
title: "Welcome!"
greeting: "Hello, %{name}!"
users:
count:
zero: "No users yet"
one: "1 user"
other: "%{count} users"
date:
formats:
short: "%b %d"
long: "%B %d, %Y"<h1>{{ t("welcome.title") }}</h1>
<p>{{ t("welcome.greeting", name=user.name) }}</p>
<span>{{ t("users.count", count=users.size) }}</span><p>Created: {{ post.created_at | l("date.short") }}</p>
<p>Published: {{ post.published_at | l("date.long") }}</p><div class="language-switcher">
{% for locale in available_locales() %}
<a href="?locale={{ locale }}"
class="{% if locale == current_locale() %}active{% endif %}">
{{ locale | locale_name }}
</a>
{% endfor %}
</div><span class="price">{{ product.price | currency("$") }}</span>
<span class="views">{{ article.views | number_with_delimiter }} views</span>
<span class="rating">{{ product.rating | percentage }} positive</span>
<span class="size">{{ file.size | filesize }}</span><small>{{ post.created_at | time_ago }}</small>
{# Output: "5 minutes ago", "2 days ago", etc. #}
<small>{{ event.starts_at | relative_time }}</small>
{# Output: "in 3 hours", "in 2 days", etc. #}<time datetime="{{ post.created_at | date_format('%Y-%m-%dT%H:%M:%SZ') }}">
{{ post.created_at | date_format("%B %d, %Y at %I:%M %p") }}
</time>{{ user_input }}
{# "<script>" becomes "<script>" #}{{ rendered_markdown | safe_html }}{{ user_comment | auto_link }}
{# URLs and emails become clickable links #}{{ article.content | highlight(search_query) }}
{# Matching text wrapped in <mark> tags #}{% extends "layouts/application.jinja" %}
{% block title %}{{ t("posts.show.title", title=post.title) }}{% endblock %}
{% block content %}
<article class="post">
<header>
<h1>{{ post.title }}</h1>
<p class="meta">
By {{ link_to(post.author.name, "/users/" ~ post.author.id) }}
<time>{{ post.created_at | time_ago }}</time>
</p>
</header>
<div class="content">
{{ post.content | safe_html }}
</div>
<footer>
{% if current_user and current_user.id == post.author.id %}
{{ link_to(t("actions.edit"), "/posts/" ~ post.id ~ "/edit", class="btn") }}
{{ button_to(t("actions.delete"), "/posts/" ~ post.id,
method="delete",
confirm=t("posts.confirm_delete"),
class="btn btn-danger") }}
{% endif %}
{{ link_to(t("actions.back"), back_url(fallback="/posts"), class="btn btn-secondary") }}
</footer>
</article>
{% endblock %}MyApp.start [
Azu::Handler::Rescuer.new,
# ... other handlers
]def call
user = User.find?(params["id"])
unless user
raise Azu::Response::NotFound.new("/users/#{params["id"]}")
end
UserResponse.new(user)
endclass ErrorHandler < Azu::Handler::Base
Log = ::Log.for(self)
def call(context)
call_next(context)
rescue ex : Azu::Response::Error
handle_known_error(context, ex)
rescue ex : JSON::ParseException
handle_json_error(context, ex)
rescue ex : CQL::RecordNotFound
handle_not_found(context, ex)
rescue ex : CQL::RecordInvalid
handle_validation_error(context, ex)
rescue ex
handle_unknown_error(context, ex)
end
private def handle_known_error(context, ex : Azu::Response::Error)
respond_with_error(context, ex.status, ex.message)
end
private def handle_json_error(context, ex)
respond_with_error(context, 400, "Invalid JSON: #{ex.message}")
end
private def handle_not_found(context, ex)
respond_with_error(context, 404, "Resource not found")
end
private def handle_validation_error(context, ex)
respond_with_error(context, 422, "Validation failed", ex.errors)
end
private def handle_unknown_error(context, ex)
Log.error(exception: ex) { "Unhandled error" }
if ENV["AZU_ENV"] == "production"
respond_with_error(context, 500, "Internal server error")
else
respond_with_error(context, 500, ex.message, ex.backtrace)
end
end
private def respond_with_error(context, status, message, details = nil)
context.response.status_code = status
context.response.content_type = "application/json"
body = {error: message}
body = body.merge({details: details}) if details
context.response.print(body.to_json)
end
endstruct CreateOrderEndpoint
include Azu::Endpoint(CreateOrderRequest, OrderResponse)
post "/orders"
def call : Azu::Response
validate_inventory
order = create_order
status 201
OrderResponse.new(order)
rescue ex : InsufficientInventoryError
status 422
ErrorResponse.new("Insufficient inventory: #{ex.message}")
rescue ex : PaymentDeclinedError
status 402
ErrorResponse.new("Payment declined: #{ex.message}")
end
private def validate_inventory
# Check inventory...
end
private def create_order
# Create order...
end
endstruct ErrorResponse
include Azu::Response
def initialize(
@message : String,
@code : String? = nil,
@details : Hash(String, String)? = nil
)
end
def render
response = {
error: {
message: @message
}
}
response[:error][:code] = @code if @code
response[:error][:details] = @details if @details
response.to_json
end
endclass ErrorLogger < Azu::Handler::Base
Log = ::Log.for(self)
def call(context)
call_next(context)
rescue ex
log_error(context, ex)
raise ex
end
private def log_error(context, ex)
Log.error(exception: ex) { {
error_class: ex.class.name,
message: ex.message,
path: context.request.path,
method: context.request.method,
request_id: context.request.headers["X-Request-ID"]?,
user_agent: context.request.headers["User-Agent"]?
}.to_json }
end
endclass ErrorReporter < Azu::Handler::Base
def call(context)
call_next(context)
rescue ex
report_error(context, ex)
raise ex
end
private def report_error(context, ex)
return if ENV["AZU_ENV"] != "production"
# Send to Sentry, Honeybadger, etc.
Sentry.capture_exception(ex, extra: {
path: context.request.path,
method: context.request.method
})
end
enddef with_retry(max_attempts = 3, &)
attempts = 0
loop do
attempts += 1
return yield
rescue ex : Timeout::Error | IO::Error
raise ex if attempts >= max_attempts
Log.warn { "Attempt #{attempts} failed, retrying..." }
sleep (2 ** attempts).seconds
end
end
# Usage
def call
with_retry do
external_api.fetch_data
end
endclass CircuitBreaker
enum State
Closed
Open
HalfOpen
end
def initialize(
@failure_threshold = 5,
@reset_timeout = 30.seconds
)
@state = State::Closed
@failures = 0
@last_failure_time = Time.utc
end
def call(&)
case @state
when .open?
if Time.utc - @last_failure_time > @reset_timeout
@state = State::HalfOpen
else
raise CircuitOpenError.new
end
end
begin
result = yield
on_success
result
rescue ex
on_failure
raise ex
end
end
private def on_success
@failures = 0
@state = State::Closed
end
private def on_failure
@failures += 1
@last_failure_time = Time.utc
if @failures >= @failure_threshold
@state = State::Open
end
end
enddef call
data = fetch_from_primary
rescue ex : ServiceUnavailableError
Log.warn { "Primary service unavailable, using cache" }
data = fetch_from_cache
if data.nil?
Log.error { "No cached data available" }
raise ex
end
data
endAzu.configure do |config|
# Set options here
endconfig.port = 8080config.host = "0.0.0.0"config.ssl = {
cert: "/path/to/cert.pem",
key: "/path/to/key.pem"
}config.reuse_port = trueconfig.env = Azu::Environment::Productionconfig.log.level = Log::Severity::Infoconfig.log.backend = Log::IOBackend.new(File.new("app.log", "a"))config.template_path = "./views"config.template_hot_reload = trueconfig.cache = Azu::Cache::RedisStore.new(url: ENV["REDIS_URL"])config.router.path_cache_size = 1000config.router.path_cache_enabled = trueconfig.max_request_size = 10 * 1024 * 1024 # 10 MBconfig.request_timeout = 30.secondsAzu.configure do |config|
# Common settings
config.port = ENV.fetch("PORT", "4000").to_i
case ENV.fetch("AZU_ENV", "development")
when "production"
config.env = Azu::Environment::Production
config.log.level = Log::Severity::Info
config.template_hot_reload = false
config.cache = Azu::Cache::RedisStore.new(url: ENV["REDIS_URL"])
when "test"
config.env = Azu::Environment::Test
config.log.level = Log::Severity::Warn
config.port = 4001
else # development
config.env = Azu::Environment::Development
config.log.level = Log::Severity::Debug
config.template_hot_reload = true
config.cache = Azu::Cache::MemoryStore.new
end
endAzu.configure do |config|
# Server
config.port = ENV.fetch("PORT", "8080").to_i
config.host = ENV.fetch("HOST", "0.0.0.0")
config.reuse_port = true
# Environment
config.env = Azu::Environment::Production
# SSL
if ENV["SSL_CERT"]? && ENV["SSL_KEY"]?
config.ssl = {
cert: ENV["SSL_CERT"],
key: ENV["SSL_KEY"]
}
end
# Logging
config.log.level = Log::Severity::Info
config.log.backend = JsonLogBackend.new(STDOUT)
# Templates
config.template_path = "./views"
config.template_hot_reload = false
# Cache
config.cache = Azu::Cache::RedisStore.new(
url: ENV["REDIS_URL"],
pool_size: 10,
namespace: "myapp:production"
)
# Router
config.router.path_cache_size = 2000
# Request limits
config.max_request_size = 20 * 1024 * 1024
config.request_timeout = 30.seconds
endclass MyHandler < Azu::Handler::Base
def call(context)
# Before processing
call_next(context)
# After processing
end
enddef call(context : HTTP::Server::Context)
# Handle request
enddef call(context)
call_next(context)
endMyApp.start [
Azu::Handler::Rescuer.new,
# ... other handlers
]{
"error": "Internal Server Error"
}MyApp.start [
Azu::Handler::Logger.new,
# ... other handlers
]2024-01-15T10:30:00Z INFO GET /users 200 15.2msAzu::Handler::Logger.new(
log: Log.for("http"),
skip_paths: ["/health", "/metrics"]
)MyApp.start [
Azu::Handler::Static.new(
public_dir: "./public",
fallthrough: true
),
# ... other handlers
]MyApp.start [
Azu::Handler::CORS.new(
allowed_origins: ["https://example.com"],
allowed_methods: ["GET", "POST", "PUT", "DELETE"],
allowed_headers: ["Content-Type", "Authorization"],
max_age: 86400
),
# ... other handlers
]Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400MyApp.start [
# 1. First: Handle errors
Azu::Handler::Rescuer.new,
# 2. Add CORS headers
Azu::Handler::CORS.new,
# 3. Log requests
Azu::Handler::Logger.new,
# 4. Serve static files
Azu::Handler::Static.new(public_dir: "./public"),
# 5. Custom middleware
AuthHandler.new,
RateLimitHandler.new,
# 6. Endpoints
UsersEndpoint.new,
PostsEndpoint.new,
]class TimingHandler < Azu::Handler::Base
def call(context)
start = Time.instant
call_next(context)
duration = Time.instant - start
context.response.headers["X-Response-Time"] = "#{duration.total_milliseconds}ms"
end
end
class AuthHandler < Azu::Handler::Base
SKIP_PATHS = ["/", "/login", "/health"]
def call(context)
path = context.request.path
if SKIP_PATHS.includes?(path)
return call_next(context)
end
token = context.request.headers["Authorization"]?
unless token && valid?(token)
context.response.status_code = 401
context.response.print({error: "Unauthorized"}.to_json)
return
end
call_next(context)
end
private def valid?(token : String) : Bool
# Validate token
true
end
endenum Azu::Environment
Development
Test
Production
end{# Forms #}
{{ form_tag("/users", method="post") }}
{{ csrf_field() }}
{{ text_field("user", "name", required=true) }}
{{ submit_button("Create") }}
{{ end_form() }}
{# Links #}
{{ link_to("Home", "/", class="nav-link") }}
{{ button_to("Delete", "/posts/1", method="delete") }}
{# Assets #}
{{ stylesheet_tag("app.css") }}
{{ javascript_tag("app.js", defer=true) }}
{{ image_tag("logo.png", alt="Logo") }}
{# i18n #}
{{ t("welcome.title") }}
{{ t("greeting", name=user.name) }}
{{ created_at | l("date.short") }}
{# Numbers #}
{{ price | currency("$") }}
{{ 1234567 | number_with_delimiter }}
{# Dates #}
{{ created_at | time_ago }}
{{ date | date_format("%Y-%m-%d") }}Float64MyDB = CQL::Schema.define(
:my_db,
adapter: CQL::Adapter::SQLite,
uri: "sqlite3://./db/development.db"
) do
table :users do
primary :id, Int64
column :name, String
column :email, String
timestamps
end
endtable :users do
primary :id, Int64
column :name, String
column :email, String
endprimary :id, Int64
primary :uuid, String # For UUID primary keyscolumn :name, String
column :age, Int32? # Nullable
column :active, Bool, default: true # With default
column :created_at, Time, default: -> { Time.utc }table :posts do
primary :id, Int64
column :title, String
timestamps # Adds created_at, updated_at
endtable :posts do
primary :id, Int64
column :user_id, Int64
foreign_key :user_id, :users, :id
endtable :users do
# ...
index :email, unique: true
index [:first_name, :last_name] # Composite
endclass User
include CQL::Model(User, Int64)
db_context MyDB, :users
property id : Int64?
property name : String
property email : String
enddb_context MyDB, :usersuser = User.create!(name: "Alice", email: "alice@example.com")user = User.find(1) # Raises if not found
user = User.find?(1) # Returns nil if not founduser = User.find_by(email: "alice@example.com")
user = User.find_by?(email: "alice@example.com")user = User.new(name: "Alice", email: "alice@example.com")
user.save! # Raises on failure
user.save # Returns Booluser.update!(name: "Alice Smith")
user.update(name: "Alice Smith")user.destroy!
user.destroyUser.delete_all
User.where(active: false).delete_allusers = User.allusers = User.where(active: true).all
users = User.where("age > ?", 18).allusers = User.order(name: :asc).all
users = User.order(created_at: :desc).allusers = User.limit(10).offset(20).allcount = User.count
count = User.where(active: true).countuser = User.first
user = User.order(created_at: :desc).first
user = User.lastexists = User.where(email: "alice@example.com").exists?class Post
include CQL::Model(Post, Int64)
property user_id : Int64
belongs_to :user, User, foreign_key: :user_id
endclass User
include CQL::Model(User, Int64)
has_many :posts, Post, foreign_key: :user_id
endclass User
include CQL::Model(User, Int64)
has_one :profile, Profile, foreign_key: :user_id
endclass User
include CQL::Model(User, Int64)
before_save :normalize_email
after_create :send_welcome_email
private def normalize_email
@email = email.downcase.strip
end
endclass User
include CQL::Model(User, Int64)
scope :active, -> { where(active: true) }
scope :recent, -> { order(created_at: :desc) }
scope :admins, -> { where(role: "admin") }
end
# Usage
User.active.recent.all
User.admins.countMyDB.transaction do
user = User.create!(name: "Alice")
Profile.create!(user_id: user.id)
endMyDB.query("SELECT * FROM users WHERE age > ?", 18)
MyDB.exec("UPDATE users SET active = ? WHERE id = ?", true, 1)Azu.configure do |config|
config.env = Azu::Environment::Production
endexport AZU_ENV=productionif Azu.env.production?
# Production-only code
end
Azu.env.development? # => Bool
Azu.env.test? # => Bool
Azu.env.production? # => Boolconfig.env = Azu::Environment::Development
config.log.level = Log::Severity::Debug
config.template_hot_reload = trueconfig.env = Azu::Environment::Test
config.log.level = Log::Severity::Warn
config.template_hot_reload = false# spec/spec_helper.cr
ENV["AZU_ENV"] = "test"
ENV["DATABASE_URL"] = "sqlite3://./test.db"
Spec.before_each do
# Reset database
TestDatabase.truncate_all
endconfig.env = Azu::Environment::Production
config.log.level = Log::Severity::Info
config.template_hot_reload = false
config.cache = Azu::Cache::RedisStore.new(url: ENV["REDIS_URL"])# .env.development
AZU_ENV=development
PORT=4000
DATABASE_URL=sqlite3://./dev.db
# .env.test
AZU_ENV=test
PORT=4001
DATABASE_URL=sqlite3://./test.db
# .env.production (never commit!)
AZU_ENV=production
PORT=8080
DATABASE_URL=postgres://user:pass@host/db
REDIS_URL=redis://redis:6379/0
SECRET_KEY=actual-secret# Load environment file based on AZU_ENV
env_file = ".env.#{ENV.fetch("AZU_ENV", "development")}"
if File.exists?(env_file)
File.each_line(env_file) do |line|
next if line.starts_with?("#") || line.blank?
key, value = line.split("=", 2)
ENV[key.strip] = value.strip
end
endmodule MyApp
include Azu
configure do
env_name = ENV.fetch("AZU_ENV", "development")
case env_name
when "production"
config.env = Environment::Production
config.port = ENV.fetch("PORT", "8080").to_i
config.host = "0.0.0.0"
# Logging
config.log.level = Log::Severity::Info
config.log.backend = JsonLogBackend.new(STDOUT)
# Templates
config.template_hot_reload = false
# Cache
config.cache = Cache::RedisStore.new(
url: ENV["REDIS_URL"],
namespace: "myapp:prod"
)
# SSL
if ssl_cert = ENV["SSL_CERT"]?
config.ssl = {cert: ssl_cert, key: ENV["SSL_KEY"]}
end
when "test"
config.env = Environment::Test
config.port = 4001
config.log.level = Log::Severity::Warn
config.template_hot_reload = false
config.cache = Cache::MemoryStore.new
else # development
config.env = Environment::Development
config.port = 4000
config.log.level = Log::Severity::Debug
config.template_hot_reload = true
config.cache = Cache::MemoryStore.new
end
end
endvalidate field_name, rule: value, ...validate name, presence: truevalidate name, length: {min: 2}
validate name, length: {max: 100}
validate name, length: {min: 2, max: 100}
validate code, length: {is: 6}validate email, format: {with: /\A[^@\s]+@[^@\s]+\z/}
validate slug, format: {with: /\A[a-z0-9-]+\z/}
validate phone, format: {with: /\A\d{10}\z/}validate age, numericality: {greater_than: 0}
validate age, numericality: {greater_than_or_equal_to: 18}
validate age, numericality: {less_than: 150}
validate age, numericality: {less_than_or_equal_to: 120}
validate quantity, numericality: {equal_to: 1}
validate count, numericality: {other_than: 0}
validate price, numericality: {odd: true}
validate pairs, numericality: {even: true}validate status, inclusion: {in: ["pending", "active", "archived"]}
validate role, inclusion: {in: Role.values.map(&.to_s)}validate username, exclusion: {in: ["admin", "root", "system"]}validate email, uniqueness: true
validate slug, uniqueness: {scope: :category_id}
validate code, uniqueness: {case_sensitive: false}validate terms_accepted, acceptance: truevalidate password, confirmation: true
# Expects password_confirmation fieldvalidate age, numericality: {greater_than: 0}, allow_nil: truevalidate bio, length: {max: 500}, allow_blank: truevalidate password, presence: true, on: :create
validate password, length: {min: 8}, on: :createvalidate phone, presence: true, if: :requires_phone?
validate nickname, presence: true, unless: :has_name?
private def requires_phone?
notification_method == "sms"
endvalidate email, presence: {message: "is required for registration"}
validate age, numericality: {greater_than: 0, message: "must be positive"}class Order
include CQL::Model(Order, Int64)
property items : Array(OrderItem)
property total : Float64
def validate
super # Run standard validations
if items.empty?
errors.add(:items, "must have at least one item")
end
if total != items.sum(&.price)
errors.add(:total, "doesn't match item sum")
end
end
enderrors.add(:field, "message")
errors.add(:base, "general error message")user = User.new(name: "")
user.valid? # => falseuser.invalid? # => trueuser.errors.each do |error|
puts "#{error.field}: #{error.message}"
end
user.errors.full_messages # => ["Name can't be blank"]
user.errors.on(:name) # => ["can't be blank"]class User
include CQL::Model(User, Int64)
before_validation :normalize_data
private def normalize_data
@email = email.downcase.strip
@name = name.strip
end
endclass User
include CQL::Model(User, Int64)
db_context MyDB, :users
property id : Int64?
property name : String
property email : String
property password_hash : String?
property age : Int32?
property role : String
property terms_accepted : Bool
# Basic validations
validate name, presence: true, length: {min: 2, max: 100}
validate email, presence: true, format: {with: /@/}, uniqueness: true
validate role, inclusion: {in: ["user", "admin", "moderator"]}
validate terms_accepted, acceptance: true, on: :create
# Conditional validation
validate age, numericality: {greater_than: 0, less_than: 150}, allow_nil: true
# Password validation only on create
validate password_hash, presence: true, on: :create
# Custom validation
def validate
super
if role == "admin" && !email.ends_with?("@company.com")
errors.add(:email, "admins must use company email")
end
end
endClient Request
β
βββββββββββββββββββββββ
β Handler Chain β
β βββββββββββββββββ β
β β Rescuer β β β Catches exceptions
β βββββββββββββββββ€ β
β β Logger β β β Logs requests
β βββββββββββββββββ€ β
β β Auth β β β Authentication
β βββββββββββββββββ€ β
β β Router β β β Route matching
β βββββββββββββββββ β
βββββββββββββββββββββββ
β
βββββββββββββββββββββββ
β Endpoint β
β βββββββββββββββββ β
β β Request ββββΌβββ Parse & Validate
β βββββββββββββββββ€ β
β β call() ββββΌβββ Business Logic
β βββββββββββββββββ€ β
β β Response ββββΌβββ Serialize Output
β βββββββββββββββββ β
βββββββββββββββββββββββ
β
Client Responseββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Azu β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β βββββββββββββββ βββββββββββββββ βββββββββββββββ β
β β Endpoints β β Channels β β Components β β
β β β β β β β β
β β HTTP β β WebSocket β β Live UI β β
β ββββββββ¬βββββββ ββββββββ¬βββββββ ββββββββ¬βββββββ β
β β β β β
β ββββββββββββββββββΌβββββββββββββββββ β
β β β
β βββββββββββββββββββββββββ΄ββββββββββββββββββββββββ β
β β Router β β
β β (Radix Tree) β β
β βββββββββββββββββββββββββ¬ββββββββββββββββββββββββ β
β β β
β βββββββββββββββββββββββββ΄ββββββββββββββββββββββββ β
β β Handler Pipeline β β
β β Rescuer β Logger β CORS β Auth β Static β β
β βββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β βββββββββββββββ βββββββββββββββ βββββββββββββββ β
β β Cache β β Templates β β Config β β
β β Memory/ β β Crinja β β Environment β β
β β Redis β β β β β β
β βββββββββββββββ βββββββββββββββ βββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββstruct UserEndpoint
include Azu::Endpoint(CreateUserRequest, UserResponse)
# β β
# Input Type Output TypeMyApp.start [
Azu::Handler::Rescuer.new, # 1. Handle errors
Azu::Handler::Logger.new, # 2. Log requests
AuthHandler.new, # 3. Authenticate
RateLimitHandler.new, # 4. Rate limit
MyEndpoint.new, # 5. Handle request
] /users
/ \
GET POST
/ \
/:id /
/
GETClient 1 ββββ
β
Client 2 ββββΌβββ Channel βββ Application Logic
β
Client 3 βββββββββββββββββββββββ WebSocket βββββββββββββββββββ
β β βββββββββββββββββββ β
β Browser DOM β β Server State β
β β βββ HTML Patches β β
βββββββββββββββββββ βββββββββββββββββββAzu
βββ Core # Configuration, startup
βββ Endpoint # Request handling
βββ Request # Input contracts
βββ Response # Output contracts
βββ Channel # WebSocket handling
βββ Component # Live components
βββ Spark # Component system
βββ Cache # Caching stores
βββ Router # Route matching
βββ Handler # Middleware
βββ Templates # HTML renderingClient ββTCPβββ Server
β
HTTP::Server accepts
β
Request object created[Rescuer] β [Logger] β [Auth] β [Endpoint]
β β β β
Wrap in Log start Check Route &
try/catch time token executeclass TimingHandler < Azu::Handler::Base
def call(context)
start = Time.instant
call_next(context) # β Request goes down
duration = Time.instant - start # β Response comes back
context.response.headers["X-Response-Time"] = "#{duration}ms"
end
endGET /users/123/posts
Router lookup:
/users β :id β /posts β GET
β
Params: {"id" => "123"}
β
Handler: UserPostsEndpointstruct CreateUserRequest
include Azu::Request
getter name : String
getter email : String
endJSON Body: {"name": "Alice", "email": "a@b.com"}
β
CreateUserRequest(name: "Alice", email: "a@b.com")
β
Validations pass
β
Available in endpoint.calldef call : UserResponse
# Access validated request
name = create_user_request.name
# Business logic
user = User.create!(name: name, email: email)
# Return typed response
UserResponse.new(user)
endstruct UserResponse
include Azu::Response
def render
{id: @user.id, name: @user.name}.to_json
end
end[Endpoint] β [Auth] β [Logger] β [Rescuer]
β β β β
Response Pass Log end Return
created through time to clientException raised in Endpoint
β
Bubbles up through handlers
β
Rescuer catches it
β
Error response returnedHTTP Upgrade Request
β
Route to Channel
β
WebSocket Handshake
β
βββββββββββββββββββββ
β on_connect β β Connection established
βββββββββββββββββββββ€
β on_message β β Each message
β on_message β
β ... β
βββββββββββββββββββββ€
β on_close β β Connection closed
βββββββββββββββββββββstruct CreateUserEndpoint
include Azu::Endpoint(CreateUserRequest, UserResponse)
post "/users"
def call : UserResponse
# Business logic here
end
endclass UsersController
def create
# What parameters are expected? Unknown until runtime
# What response format? Could be anything
# Is input valid? Must check manually
end
endstruct CreateUserEndpoint
include Azu::Endpoint(CreateUserRequest, UserResponse)
# β Input declared β Output declared
post "/users"
# Route is part of the endpoint, not separate
def call : UserResponse
# Input is validated before this runs
# Return type is enforced by compiler
end
endget "/users" # GET request
post "/users" # POST request
put "/users/:id" # PUT with parameter
delete "/users/:id" # DELETE requestinclude Azu::Endpoint(CreateUserRequest, UserResponse)
# β This typeinclude Azu::Endpoint(EmptyRequest, UserResponse)include Azu::Endpoint(CreateUserRequest, UserResponse)
# β This type
def call : UserResponse # Must return this
UserResponse.new(user)
enddef call : UserResponse
# Access validated request data
name = create_user_request.name
# Perform business logic
user = User.create!(name: name)
# Return typed response
UserResponse.new(user)
enddef call : UserResponse
# Route parameters
id = params["id"]
# Request headers
auth = headers["Authorization"]?
# Full request object
method = request.method
path = request.path
# Response object
response.headers["X-Custom"] = "value"
status 201
# Full HTTP context
context.request
context.response
endstruct CreateUserRequest
include Azu::Request
getter name : String
getter email : String
end
struct CreateUserEndpoint
include Azu::Endpoint(CreateUserRequest, UserResponse)
def call : UserResponse
# Method name is snake_case of request type
create_user_request.name
create_user_request.email
end
end# One endpoint per route
struct UsersIndex
include Azu::Endpoint(EmptyRequest, UsersResponse)
get "/users"
end
struct UsersShow
include Azu::Endpoint(EmptyRequest, UserResponse)
get "/users/:id"
end
struct UsersCreate
include Azu::Endpoint(CreateUserRequest, UserResponse)
post "/users"
endstruct MyEndpoint # Struct
include Azu::Endpoint(Request, Response)
enddef call : UserResponse
user = User.find?(params["id"])
unless user
raise Azu::Response::NotFound.new("/users/#{params["id"]}")
end
UserResponse.new(user)
enddescribe CreateUserEndpoint do
it "creates a user" do
context = create_test_context(
method: "POST",
path: "/users",
body: {name: "Alice"}.to_json
)
endpoint = CreateUserEndpoint.new
endpoint.context = context
response = endpoint.call
response.should be_a(UserResponse)
end
end{{ form_tag("/users", method="post", class="form") }}
{# form contents #}
{{ end_form() }}{{ end_form() }}{{ csrf_field() }}
{# Output: <input type="hidden" name="_csrf" value="token123..." /> #}{{ csrf_meta() }}
{# Output: <meta name="csrf-token" content="token123..." /> #}{{ text_field("user", "name", placeholder="Enter name", required=true) }}{{ email_field("user", "email", required=true) }}{{ password_field("user", "password", minlength=8) }}{{ number_field("product", "quantity", min=1, max=100, step=1) }}
{{ number_field("product", "price", step="0.01") }}{{ textarea("post", "content", rows=5, cols=40) }}{{ hidden_field("user", "id", value=user.id) }}{{ checkbox("user", "active", checked=true) }}
{{ checkbox("user", "newsletter", label="Subscribe to newsletter") }}{{ radio_button("user", "role", value="admin", label="Administrator") }}
{{ radio_button("user", "role", value="user", label="Regular User", checked=true) }}{{ select_field("user", "country", options=[
{"value": "us", "label": "United States"},
{"value": "ca", "label": "Canada"},
{"value": "uk", "label": "United Kingdom"}
], selected="us", include_blank="Select country...") }}{{ label_tag("user_email", "Email Address") }}{{ submit_button("Create Account", class="btn btn-primary") }}{{ link_to("Home", "/", class="nav-link") }}
{{ link_to("Docs", "/docs", target="_blank") }}{{ button_to("Delete", "/users/1", method="delete", confirm="Are you sure?") }}{{ mail_to("support@example.com", "Contact Us") }}
{{ mail_to("support@example.com", "Email", subject="Hello", body="Message here") }}{{ current_path() }} {# e.g., "/users" #}{{ current_url() }} {# e.g., "https://example.com/users" #}{% if "/" | is_current_page %}
<span class="active">Home</span>
{% endif %}<a href="/" class="nav-link {{ '/' | active_class('active') }}">Home</a>
<a href="/about" class="nav-link {{ '/about' | active_class('active', inactive_class='inactive') }}">About</a>{{ link_to("Back", back_url(fallback="/posts")) }}# Endpoint definition
class UsersEndpoint
include Azu::Endpoint(UsersRequest, UsersResponse)
get "/users"
end
class UserEndpoint
include Azu::Endpoint(UserRequest, UserResponse)
get "/users/:id"
end
class CreateUserEndpoint
include Azu::Endpoint(CreateUserRequest, UserResponse)
post "/users"
end
class UpdateUserEndpoint
include Azu::Endpoint(UpdateUserRequest, UserResponse)
put "/users/:id"
end
class DeleteUserEndpoint
include Azu::Endpoint(DeleteUserRequest, EmptyResponse)
delete "/users/:id"
end{# Collection endpoint (no id parameter) #}
{{ link_to_get_users("View All Users") }}
{# Output: <a href="/users">View All Users</a> #}
{# Member endpoint (with id parameter) #}
{{ link_to_get_user("View User", id="123") }}
{# Output: <a href="/users/123">View User</a> #}
{# Uses path as text when no text provided #}
{{ link_to_get_users() }}
{# Output: <a href="/users">/users</a> #}{{ link_to_get_users("Users", params={'page': '2', 'per_page': '10'}) }}
{# Output: <a href="/users?page=2&per_page=10">Users</a> #}
{{ link_to_get_user("View User", id="123", params={'tab': 'profile'}) }}
{# Output: <a href="/users/123?tab=profile">View User</a> #}{# POST form #}
{{ form_for_post_create_user(class="user-form") }}
{{ csrf_field() }}
{{ text_field("user", "name") }}
{{ submit_button("Create") }}
{{ end_form() }}
{# Output: <form action="/users" method="post" class="user-form">... #}
{# PUT form (auto-includes _method hidden field) #}
{{ form_for_put_update_user(id="123", class="edit-form") }}
{{ csrf_field() }}
{{ text_field("user", "name", value=user.name) }}
{{ submit_button("Update") }}
{{ end_form() }}
{# Output: <form action="/users/123" method="post" class="edit-form">
<input type="hidden" name="_method" value="put">... #}
{# DELETE form #}
{{ form_for_delete_delete_user(id="123") }}
{{ csrf_field() }}
{{ submit_button("Confirm Delete") }}
{{ end_form() }}{{ form_for_post_create_user(params={'redirect_to': '/dashboard', 'source': 'signup'}) }}
{{ csrf_field() }}
{# generates: <input type="hidden" name="redirect_to" value="/dashboard">
<input type="hidden" name="source" value="signup"> #}
{{ text_field("user", "name") }}
{{ submit_button("Create") }}
{{ end_form() }}{{ button_to_delete_delete_user(id="123") }}
{# Output:
<form action="/users/123" method="post" style="display:inline">
<input type="hidden" name="_method" value="delete">
<button type="submit">Delete</button>
</form>
#}
{# With custom text and confirmation #}
{{ button_to_delete_delete_user(text="Remove User", id="123", confirm="Are you sure?") }}
{# Output:
<form action="/users/123" method="post" style="display:inline">
<input type="hidden" name="_method" value="delete">
<button type="submit" onclick="return confirm('Are you sure?')">Remove User</button>
</form>
#}{{ button_to_delete_delete_user(id="123", params={'redirect': '/users', 'source': 'list'}) }}
{# Generates form with additional hidden fields:
<input type="hidden" name="redirect" value="/users">
<input type="hidden" name="source" value="list">
#}{{ "images/logo.png" | asset_path }} {# e.g., "/assets/images/logo.png" #}{{ image_tag("logo.png", alt="Logo", width=200) }}
{{ image_tag("hero.jpg", alt="Hero", class="hero-image", loading="lazy") }}{{ javascript_tag("app.js") }}
{{ javascript_tag("app.js", defer=true) }}
{{ javascript_tag("https://example.com/lib.js", async=true) }}{{ stylesheet_tag("app.css") }}
{{ stylesheet_tag("print.css", media="print") }}{{ favicon_tag("favicon.ico") }}{{ created_at | time_ago }} {# e.g., "5 minutes ago", "2 days ago" #}{{ event_date | relative_time }} {# e.g., "in 3 days", "2 hours ago" #}{{ date | date_format }} {# January 15, 2024 #}
{{ date | date_format("%Y-%m-%d") }} {# 2024-01-15 #}
{{ date | date_format("%b %d, %Y") }} {# Jan 15, 2024 #}
{{ date | date_format("%H:%M:%S") }} {# 14:30:00 #}{{ time_tag(time=created_at, format="%B %d, %Y") }}
{# Output: <time datetime="2024-01-15T10:30:00Z">January 15, 2024</time> #}{{ 45 | distance_of_time }} {# 45 seconds #}
{{ 150 | distance_of_time }} {# 2 minutes #}
{{ 7200 | distance_of_time }} {# 2 hours #}{{ 1234.5 | currency("$") }} {# $1,234.50 #}
{{ 1234.5 | currency("β¬") }} {# β¬1,234.50 #}
{{ 1234.567 | currency("$", precision=3) }} {# $1,234.567 #}{{ 1234567 | number_with_delimiter }} {# 1,234,567 #}
{{ 1234567 | number_with_delimiter(delimiter=".") }} {# 1.234.567 #}{{ 0.756 | percentage }} {# 76% #}
{{ 0.756 | percentage(precision=1) }} {# 75.6% #}{{ 1024 | filesize }} {# 1.0 KB #}
{{ 1048576 | filesize }} {# 1.0 MB #}
{{ 1073741824 | filesize }} {# 1.0 GB #}{{ 1234 | number_to_human }} {# 1.23 thousand #}
{{ 1234567 | number_to_human }} {# 1.23 million #}
{{ 1234567890 | number_to_human }} {# 1.23 billion #}{{ html_content | safe_html }}{{ text | simple_format }}
{{ text | simple_format(tag="div") }}{{ text | highlight("search term") }}
{# Wraps matches in <mark>...</mark> #}
{{ text | highlight("term", highlighter="<strong>\\0</strong>") }}{{ html_content | truncate_html(100) }}
{{ html_content | truncate_html(100, omission="...") }}{{ html_content | strip_tags }}
{# "<p>Hello <b>World</b></p>" β "Hello World" #}{{ text | word_wrap(line_width=80) }}
{{ text | word_wrap(line_width=60, break_char="\n") }}{{ text | auto_link }}
{# "Visit https://example.com" β "Visit <a href="...">...</a>" #}{{ content_tag(name="div", content="Hello", class="container") }}
{# <div class="container">Hello</div> #}{{ t("welcome.title") }}
{{ t("greeting", name=user.name) }}
{{ t("users.count", count=5) }}
{{ t("missing.key", default="Fallback text") }}# locales/en.yml
en:
users:
count:
zero: "No users"
one: "1 user"
other: "%{count} users"{{ t("users.count", count=0) }} {# No users #}
{{ t("users.count", count=1) }} {# 1 user #}
{{ t("users.count", count=5) }} {# 5 users #}{{ created_at | l("date.short") }} {# Jan 15 #}
{{ created_at | l("date.long") }} {# January 15, 2024 #}{{ current_locale() }} {# en #}{% for locale in available_locales() %}
<a href="?locale={{ locale }}">{{ locale | locale_name }}</a>
{% endfor %}{{ "en" | locale_name }} {# English #}
{{ "es" | locale_name }} {# Spanish #}{{ pluralize(count=items.size, singular="item", plural="items") }}{{ spark_tag() }}{{ render_component("counter", initial_count=0) }}<button {{ "increment" | live_click }}>+</button>
<input type="text" {{ "search" | live_input }}>
<select {{ "update" | live_change }}>...</select>{% extends "layouts/application.jinja" %}
{% block title %}{{ t("users.index.title") }}{% endblock %}
{% block content %}
<div class="container">
<h1>{{ t("users.index.heading") }}</h1>
{% if flash.notice %}
<div class="alert alert-success">{{ flash.notice }}</div>
{% endif %}
{{ form_tag("/users", method="post", class="user-form") }}
{{ csrf_field() }}
<div class="form-group">
{{ label_tag("user_name", t("users.form.name")) }}
{{ text_field("user", "name", required=true, class="form-control") }}
</div>
<div class="form-group">
{{ label_tag("user_email", t("users.form.email")) }}
{{ email_field("user", "email", required=true, class="form-control") }}
</div>
{{ submit_button(t("users.form.submit"), class="btn btn-primary") }}
{{ end_form() }}
<table class="table">
<thead>
<tr>
<th>{{ t("users.table.name") }}</th>
<th>{{ t("users.table.created") }}</th>
<th>{{ t("users.table.actions") }}</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.name }}</td>
<td>{{ user.created_at | time_ago }}</td>
<td>
{{ link_to(t("actions.edit"), "/users/" ~ user.id ~ "/edit", class="btn btn-sm") }}
{{ button_to(t("actions.delete"), "/users/" ~ user.id, method="delete",
confirm=t("users.confirm_delete")) }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p>{{ t("users.count", count=users.size) }}</p>
</div>
{% endblock %}# Before (0.4.x)
struct MyEndpoint
include Azu::Endpoint
def call
# ...
end
end
# After (0.5.x)
struct MyEndpoint
include Azu::Endpoint(MyRequest, MyResponse)
def call : MyResponse
# ...
end
end# Before
request.name
# After
my_request.name # Method name derived from request type
create_user_request.name # For CreateUserRequest# Before (0.3.x)
def call(context)
# ...
next.try &.call(context)
end
# After (0.4.x)
def call(context)
# ...
call_next(context)
end# Before (0.3.x)
Azu.port = 8080
Azu.env = :production
# After (0.4.x)
Azu.configure do |config|
config.port = 8080
config.env = Environment::Production
end# Before (0.2.x)
Azu.router.add("GET", "/users", UsersEndpoint)
# After (0.3.x)
struct UsersEndpoint
include Azu::Endpoint
get "/users" # Route defined with macro
def call
# ...
end
end# Before (0.2.x)
def call
{users: users}.to_json
end
# After (0.3.x)
struct UsersResponse
include Azu::Response
def render
{users: @users}.to_json
end
end# Before (0.1.x)
Azu.channel("/chat") do |socket, message|
# Handle message
end
# After (0.2.x)
class ChatChannel < Azu::Channel
PATH = "/chat"
def on_message(message)
# Handle message
end
endcrystal build --warnings=all src/app.cr<h1>{{ title }}</h1>
<p>{{ user.name }}</p>
<p>{{ items[0] }}</p>{% if user %}
<p>Hello, {{ user.name }}</p>
{% endif %}{# This is a comment #}
{#
Multi-line
comment
#}{% if user.admin %}
<span class="badge">Admin</span>
{% elif user.moderator %}
<span class="badge">Moderator</span>
{% else %}
<span class="badge">User</span>
{% endif %}{% for item in items %}
<li>{{ item.name }}</li>
{% endfor %}
{% for item in items %}
<li>{{ item }}</li>
{% else %}
<li>No items found</li>
{% endfor %}{% for user in users %}
<tr class="{% if loop.first %}first{% endif %}">
<td>{{ loop.index }}</td>
<td>{{ user.name }}</td>
</tr>
{% endfor %}{% for user in users if user.active %}
<li>{{ user.name }}</li>
{% endfor %}{% extends "layouts/base.html" %}<!-- layouts/base.html -->
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}Default Title{% endblock %}</title>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html><!-- pages/home.html -->
{% extends "layouts/base.html" %}
{% block title %}Home Page{% endblock %}
{% block content %}
<h1>Welcome!</h1>
{% endblock %}{% block content %}
{{ super() }}
<p>Additional content</p>
{% endblock %}{% include "partials/header.html" %}
{% include "partials/footer.html" %}{% include "partials/user_card.html" with user=current_user %}
{% include "partials/item.html" with item=item, index=loop.index %}{% include "partials/optional.html" ignore missing %}{% macro input(name, value="", type="text") %}
<input type="{{ type }}" name="{{ name }}" value="{{ value }}" class="form-input">
{% endmacro %}{{ input("username") }}
{{ input("email", user.email, "email") }}
{{ input("password", type="password") }}{% import "macros/forms.html" as forms %}
{{ forms.input("name") }}{{ name | upper }}
{{ name | lower }}
{{ name | capitalize }}
{{ name | title }}{{ value | default("N/A") }}
{{ user.name | default("Anonymous") }}{{ name | trim | lower | truncate(20) }}{% if number is even %}
{% if name is defined %}
{% if items is empty %}
{% if value is none %}
{% if text is string %}{% if age >= 18 %}
{% if status == "active" %}
{% if name != "" %}{% if user and user.active %}
{% if admin or moderator %}
{% if not banned %}{{ price * quantity }}
{{ total / items }}
{{ count + 1 }}
{{ index % 2 }}{{ first_name ~ " " ~ last_name }}{% if "admin" in roles %}
{% if user.id in allowed_ids %}{% for item in items -%}
{{ item }}
{%- endfor %}{% raw %}
This {{ will not }} be processed
{% endraw %}class Azu::Response::Error < Exception
getter status : Int32
getter message : String
getter context : ErrorContext?
def initialize(@message : String, @status : Int32 = 500, @context : ErrorContext? = nil)
end
endraise Azu::Response::BadRequest.new("Invalid JSON")
raise Azu::Response::BadRequest.new("Missing required field: email")raise Azu::Response::Unauthorized.new
raise Azu::Response::Unauthorized.new("Invalid token")raise Azu::Response::Forbidden.new
raise Azu::Response::Forbidden.new("Admin access required")raise Azu::Response::NotFound.new("/users/123")
raise Azu::Response::NotFound.new("User not found")raise Azu::Response::MethodNotAllowed.new(["GET", "POST"])raise Azu::Response::Conflict.new("Email already registered")
raise Azu::Response::Conflict.new("Resource version mismatch")raise Azu::Response::Gone.new("This API endpoint has been removed")raise Azu::Response::UnprocessableEntity.new("Validation failed")raise Azu::Response::ValidationError.new([
{field: "email", message: "is invalid"},
{field: "name", message: "is required"}
]){
"error": "Validation failed",
"details": [
{"field": "email", "message": "is invalid"},
{"field": "name", "message": "is required"}
]
}raise Azu::Response::TooManyRequests.new
raise Azu::Response::TooManyRequests.new("Rate limit exceeded. Try again in 60 seconds.")raise Azu::Response::InternalServerError.new
raise Azu::Response::InternalServerError.new("Database connection failed")raise Azu::Response::NotImplemented.new("This feature is coming soon")raise Azu::Response::BadGateway.new("Payment gateway error")raise Azu::Response::ServiceUnavailable.new("Database maintenance in progress")
raise Azu::Response::ServiceUnavailable.new("High traffic, please retry")raise Azu::Response::GatewayTimeout.new("Payment service timeout")struct ErrorContext
property request_id : String?
property path : String?
property method : String?
property user_id : Int64?
property timestamp : Time
def self.from_http_context(context : HTTP::Server::Context, request_id : String? = nil)
new(
request_id: request_id,
path: context.request.path,
method: context.request.method,
timestamp: Time.utc
)
end
endclass InsufficientFundsError < Azu::Response::Error
getter required : Float64
getter available : Float64
def initialize(@required : Float64, @available : Float64)
super("Insufficient funds: need $#{@required}, have $#{@available}", 422)
end
end
class RateLimitError < Azu::Response::Error
getter retry_after : Int32
def initialize(@retry_after : Int32)
super("Rate limit exceeded", 429)
end
def headers : Hash(String, String)
{"Retry-After" => @retry_after.to_s}
end
end{
"error": "Error message here"
}{
"error": "Validation failed",
"details": [
{"field": "email", "message": "is invalid"}
]
}{
"error": "Error message",
"backtrace": ["file.cr:10", "file.cr:5"]
}def create
# What fields are expected?
name = params[:name]
email = params[:email]
# What if name is missing? Runtime error
# What if email is wrong type? Runtime error
endstruct CreateUserRequest
include Azu::Request
getter name : String
getter email : String
endstruct CreateUserRequest
include Azu::Request
getter name : String
getter email : String
getter age : Int32? # Optional
def initialize(@name = "", @email = "", @age = nil)
end
validate name, presence: true, length: {min: 2}
validate email, presence: true, format: /@/
endstruct UserResponse
include Azu::Response
def initialize(@user : User)
end
def render
{
id: @user.id,
name: @user.name,
email: @user.email
}.to_json
end
endstruct MyEndpoint
include Azu::Endpoint(Request, UserResponse)
def call : UserResponse
# Must return UserResponse
"string" # Compile error!
end
endstruct SearchRequest
include Azu::Request
getter query : String # Search term
getter page : Int32 = 1 # Page number
getter per_page : Int32 = 20 # Results per page
getter sort : String = "date" # Sort field
endstruct CreateOrderRequest
include Azu::Request
getter items : Array(OrderItem)
getter shipping_address : String
getter payment_method : String
validate items, presence: true
validate shipping_address, presence: true
validate payment_method, inclusion: {in: ["card", "paypal", "bank"]}
def validate
super
if items.empty?
errors << Error.new(:items, "must have at least one item")
end
end
enddef call
# age is Int32?, not String
if age = create_user_request.age
validate_age(age) # Compiler knows it's Int32
end
end# Before
struct UserResponse
def initialize(@user : User)
end
end
# After - add required field
struct UserResponse
def initialize(@user : User, @permissions : Array(String))
end
end
# All usages that don't provide permissions will fail to compilestruct GetUserEndpoint
include Azu::Endpoint(EmptyRequest, UserResponse)
get "/users/:id"
def call : UserResponse
# No request body to parse
# Use params for route parameters
id = params["id"]
end
endstruct DeleteUserEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Empty)
delete "/users/:id"
def call
User.find(params["id"]).destroy
status 204
Azu::Response::Empty.new
end
endstruct OrderItem
include JSON::Serializable
property product_id : Int64
property quantity : Int32
end
struct CreateOrderRequest
include Azu::Request
getter items : Array(OrderItem)
getter notes : String?
endmodule V1
struct UserResponse
include Azu::Response
# V1 format
end
end
module V2
struct UserResponse
include Azu::Response
# V2 format with additional fields
end
endstruct CreateUserRequest # For creation
struct UpdateUserRequest # For updates (might have optional fields)struct SearchProductsRequest # Clear purpose
struct ProductListResponse # Clear output# Good: focused response
struct UserResponse
def initialize(@user : User)
end
end
# Avoid: kitchen-sink response
struct BigResponse
def initialize(@user, @posts, @comments, @notifications, ...)
end
endCrystal Source β Crystal Compiler β LLVM IR β Machine Code
β
Optimized native binarystruct UserResponse
include Azu::Response
def initialize(@user : User)
end
end /
users
/ \
GET :id
| / \
Index GET DELETE# Router maintains LRU cache
PathCache = LRUCache(String, RouteMatch).new(1000)
def find(method, path)
key = "#{method}:#{path}"
if cached = PathCache.get(key)
return cached
end
match = tree.find(path)
PathCache.set(key, match)
match
end# Body not parsed until accessed
def call
# If you never access the request body,
# it's never parsed
headers["Accept"] # Just header access, no body parsing
enddef call
# Stream without loading entire body
request.body.each_chunk do |chunk|
process(chunk)
end
end# Compile-time known handler chain
handlers = [Rescuer.new, Logger.new, MyEndpoint.new]
# At runtime: direct calls
handlers[0].call(context)
β handlers[1].call(context)
β handlers[2].call(context)MyApp.start [
Azu::Handler::Rescuer.new, # Created once
Azu::Handler::Logger.new, # Reused for all requests
MyEndpoint.new,
]CONTENT_TYPE_JSON = "application/json"
CONTENT_TYPE_HTML = "text/html; charset=utf-8"
# No string allocation per request
response.headers["Content-Type"] = CONTENT_TYPE_JSONstruct User
include JSON::Serializable
property id : Int64
property name : String
end
# Compiles to direct field access, no reflection
user.to_json # Efficient serialization# Development: optional hot-reload
# Production: compiled once, cached forever
if config.template_hot_reload
template = compile_template(path)
else
template = TemplateCache.fetch(path) { compile_template(path) }
endclass ComponentPool
MAX_SIZE = 50
@pool = [] of Component
def acquire : Component
@pool.pop? || Component.new
end
def release(component : Component)
if @pool.size < MAX_SIZE
component.reset
@pool << component
end
end
end# Each request runs in a fiber
# Thousands of fibers can run concurrently
spawn do
handle_request(request)
end# This doesn't block the thread
response = HTTP::Client.get(url)
# While waiting, other fibers run# Database connections reused
AcmeDB = CQL::Schema.define(..., pool_size: 20)
# HTTP clients maintain connection pools
HTTP::Client.new(host, pool: true)# CPU profiling
crystal build --release src/app.cr
perf record -g ./app
perf report
# Memory profiling
crystal build --release -D gc_stats src/app.cr
./app # Prints GC statisticsstruct Point # Stack allocated
property x : Int32
property y : Int32
end# Bad
result = ""
items.each { |i| result += i.to_s }
# Good
result = String.build do |io|
items.each { |i| io << i }
enddef expensive_computation
@cached_result ||= compute_value
end# Instead of individual inserts
users.each { |u| u.save }
# Use bulk insert
User.insert_all(users)user = User.find(1) # Returns User or raises
user = User.find?(1) # Returns User or niluser = User.find_by(email: "alice@example.com")
user = User.find_by?(email: "alice@example.com") # Returns nil if not foundusers = User.all # Returns Array(User)first_user = User.first
last_user = User.last
oldest = User.order(created_at: :asc).firstactive_users = User.where(active: true).all
admins = User.where(role: "admin").allusers = User.where(active: true, role: "admin").all# Greater than
adults = User.where("age > ?", 18).all
# Less than or equal
recent = Post.where("created_at >= ?", 1.week.ago).all
# LIKE
users = User.where("name LIKE ?", "%Smith%").all
# IN
users = User.where("role IN (?)", ["admin", "moderator"]).all
# NULL
unverified = User.where("verified_at IS NULL").allusers = User
.where(active: true)
.where("age >= ?", 18)
.where("created_at > ?", 1.month.ago)
.all# Single column
users = User.order(name: :asc).all
users = User.order(created_at: :desc).all
# Multiple columns
users = User.order(role: :asc, name: :asc).all
# Raw SQL
users = User.order("LOWER(name) ASC").all# Limit
first_ten = User.limit(10).all
# Offset
page_two = User.limit(10).offset(10).all
# Pagination helper
def paginate(page : Int32, per_page = 20)
User.limit(per_page).offset((page - 1) * per_page).all
end# Select specific columns
names = User.select(:id, :name).all
# Select with alias
data = User.select("id, name as username").alltotal = User.count
active_count = User.where(active: true).counttotal_orders = Order.sum(:amount)
average_age = User.average(:age)
oldest = User.maximum(:age)
youngest = User.minimum(:age)# Count by role
User.select("role, COUNT(*) as count")
.group(:role)
.all
# Sum by category
Order.select("category, SUM(amount) as total")
.group(:category)
.order("total DESC")
.allposts = Post.join(:users, "users.id = posts.user_id")
.select("posts.*, users.name as author_name")
.allusers = User.left_join(:posts, "posts.user_id = users.id")
.select("users.*, COUNT(posts.id) as post_count")
.group("users.id")
.all# Using defined associations
user = User.find(1)
posts = user.posts.where(published: true).allclass Post
include CQL::Model(Post, Int64)
scope :published, -> { where(published: true) }
scope :recent, -> { order(created_at: :desc) }
scope :by_author, ->(user_id : Int64) { where(user_id: user_id) }
scope :popular, -> { where("views > ?", 100) }
end
# Use scopes
Post.published.recent.all
Post.by_author(user.id).published.limit(5).all
Post.published.popular.countresults = AcmeDB.query("SELECT * FROM users WHERE age > ?", 21)AcmeDB.exec("UPDATE users SET last_login = ? WHERE id = ?", Time.utc, user_id)sql = <<-SQL
SELECT u.name, COUNT(p.id) as post_count
FROM users u
LEFT JOIN posts p ON p.user_id = u.id
WHERE u.active = true
GROUP BY u.id
HAVING COUNT(p.id) > 5
ORDER BY post_count DESC
LIMIT 10
SQL
results = AcmeDB.query(sql)# Without eager loading (N+1 problem)
posts = Post.all
posts.each { |p| puts p.user.name } # One query per post!
# With eager loading
posts = Post.includes(:user).all
posts.each { |p| puts p.user.name } # Single query for usersexists = User.where(email: "alice@example.com").exists?
any = User.where(role: "admin").any?
none = User.where(role: "banned").none?class CounterComponent
include Azu::Component
@count = 0
def content
<<-HTML
<div>
<span>Count: #{@count}</span>
<button azu-click="increment">+</button>
</div>
HTML
end
on_event "increment" do
@count += 1
push_state
end
endβββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Browser β
β βββββββββββββββββββββββββββββββββββββββββββββββ β
β β Rendered HTML β β
β β <button azu-click="increment">+</button> β β
β βββββββββββββββββββββββββββββββββββββββββββββββ β
β β β β
β β Click Event β HTML Patch β
β β β β
β βββββββββββββββββββββββββββββββββββββββββββββββ β
β β Spark JavaScript β β
β βββββββββββββββββββββββββββββββββββββββββββββββ β
β β β β
β β WebSocket β WebSocket β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Server β
β βββββββββββββββββββββββββββββββββββββββββββββββ β
β β CounterComponent β β
β β @count = 5 β β
β β β β
β β on_event "increment" β @count += 1 β β
β β β push_state β β
β βββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββclass MyComponent
include Azu::Component
def mount(socket)
# Called when component first connects
# Load initial data
@data = load_data
push_state
end
def unmount
# Called when component disconnects
# Clean up resources
end
def content
# Called to render HTML
end
endmount β content β [events/updates] β content β ... β unmountclass TodoComponent
include Azu::Component
@todos = [] of Todo
@new_todo = ""
def content
# Uses @todos and @new_todo
end
endon_event "add_todo" do
@todos << Todo.new(@new_todo)
@new_todo = ""
push_state # Re-render and send to client
end<button azu-click="delete">Delete</button>
<input azu-change="update_name">
<form azu-submit="save"><button azu-click="delete" azu-value="123">Delete Item 123</button>on_event "delete" do |id|
@items.reject! { |i| i.id == id.as_i }
push_state
end<input azu-model="name" value="#{@name}">on_event "change" do
@data = new_data
push_state # Full re-render
endon_event "add_item" do
item = create_item
@items << item
# Only append the new item
push_append("#items-list", render_item(item))
end
on_event "remove_item" do |id|
@items.reject! { |i| i.id == id.as_i }
# Only remove that element
push_remove("#item-#{id}")
endclass UserCardComponent
include Azu::Component
property user_id : Int64
def mount(socket)
@user = User.find(user_id)
push_state
end
end<div azu-component="UserCardComponent"
azu-props='{"user_id": 42}'></div>class DashboardComponent
include Azu::Component
def mount(socket)
# Subscribe to data changes
EventBus.subscribe(:order_created) do |order|
@orders.unshift(order)
push_state
end
end
enddef mount(socket)
spawn do
loop do
@stats = fetch_stats
push_state
sleep 30.seconds
end
end
end# Child emits event
<button azu-click="item_selected" azu-value="#{item.id}">Select</button>
# Parent listens
<div azu-component="ChildComponent"
azu-on-item_selected="handle_selection"></div># Component A
on_event "filter_changed" do |filter|
EventBus.publish(:filter, filter)
end
# Component B
def mount(socket)
EventBus.subscribe(:filter) do |filter|
@current_filter = filter
push_state
end
endClient: "Give me data"
Server: "Here's data"
Client: "Give me data again"
Server: "Here's data"
...Client ββ Server
Persistent connection
Messages flow both ways
Low latencyclass ChatChannel < Azu::Channel
PATH = "/chat"
def on_connect
# Connection established
end
def on_message(message : String)
# Message received
end
def on_close(code, reason)
# Connection closed
end
endClient connects via WebSocket
β
on_connect
β
ββββββββββ
β Loop β βββ on_message (for each message)
ββββββββββ
β
on_closeclass NotificationChannel < Azu::Channel
PATH = "/notifications"
@@connections = [] of HTTP::WebSocket
def on_connect
@@connections << socket
send_welcome
end
def on_close(code, reason)
@@connections.delete(socket)
end
enddef self.broadcast(message : String)
@@connections.each do |socket|
socket.send(message)
end
enddef on_message(message : String)
data = JSON.parse(message)
case data["type"]?.try(&.as_s)
when "subscribe"
handle_subscribe(data)
when "message"
handle_message(data)
when "ping"
send({type: "pong"}.to_json)
end
endconst ws = new WebSocket('ws://localhost:4000/chat');
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'subscribe',
room: 'general'
}));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleMessage(data);
};class RoomChannel < Azu::Channel
PATH = "/rooms/:room_id"
@@rooms = Hash(String, Set(HTTP::WebSocket)).new { |h, k|
h[k] = Set(HTTP::WebSocket).new
}
def on_connect
room = params["room_id"]
@@rooms[room] << socket
end
def on_message(message)
room = params["room_id"]
broadcast_to_room(room, message)
end
def on_close(code, reason)
room = params["room_id"]
@@rooms[room].delete(socket)
end
private def broadcast_to_room(room, message)
@@rooms[room].each { |ws| ws.send(message) }
end
endclass UserChannel < Azu::Channel
@@user_sockets = Hash(Int64, Set(HTTP::WebSocket)).new { |h, k|
h[k] = Set(HTTP::WebSocket).new
}
def on_connect
if user = authenticate
@@user_sockets[user.id] << socket
end
end
def self.send_to_user(user_id : Int64, message : String)
@@user_sockets[user_id].each { |ws| ws.send(message) }
end
end
# From anywhere in your app:
UserChannel.send_to_user(user.id, notification.to_json)class PresenceChannel < Azu::Channel
@@online_users = Set(Int64).new
def on_connect
if user = authenticate
@@online_users << user.id
broadcast_presence_update
end
end
def on_close(code, reason)
if user = @current_user
@@online_users.delete(user.id)
broadcast_presence_update
end
end
def self.online?(user_id : Int64)
@@online_users.includes?(user_id)
end
enddef on_connect
token = context.request.query_params["token"]?
if token && (user = validate_token(token))
@current_user = user
send({type: "authenticated"}.to_json)
else
send({type: "error", message: "Unauthorized"}.to_json)
socket.close
end
endconst token = getAuthToken();
const ws = new WebSocket(`ws://localhost:4000/channel?token=${token}`);@@connections = [] of HTTP::WebSocketclass ScalableChannel < Azu::Channel
@@redis = Redis.new
def on_connect
spawn do
@@redis.subscribe("channel:messages") do |on|
on.message do |_, msg|
socket.send(msg)
end
end
end
end
def self.broadcast(message)
@@redis.publish("channel:messages", message)
end
enddef on_message(message)
process(message)
rescue JSON::ParseException
send({type: "error", code: "INVALID_JSON"}.to_json)
rescue ex
Log.error { "Channel error: #{ex.message}" }
send({type: "error", code: "INTERNAL_ERROR"}.to_json)
endRequest β Handler 1 β Handler 2 β Handler 3 β Endpoint
β
Response β Handler 1 β Handler 2 β Handler 3 β Responseclass MyHandler < Azu::Handler::Base
def call(context)
# Before request processing
call_next(context)
# After request processing
end
enddef call(context)
puts "Before"
call_next(context)
puts "After"
endBefore
Before (next handler)
[Endpoint executes]
After (next handler)
AfterMyApp.start [
Azu::Handler::Rescuer.new, # 1. Outermost
Azu::Handler::Logger.new, # 2.
AuthHandler.new, # 3.
RateLimitHandler.new, # 4.
MyEndpoint.new, # 5. Innermost
]class RequestIdHandler < Azu::Handler::Base
def call(context)
request_id = context.request.headers["X-Request-ID"]?
request_id ||= UUID.random.to_s
context.request.headers["X-Request-ID"] = request_id
call_next(context)
end
endclass SecurityHeadersHandler < Azu::Handler::Base
def call(context)
call_next(context)
context.response.headers["X-Frame-Options"] = "DENY"
context.response.headers["X-Content-Type-Options"] = "nosniff"
end
endclass AuthHandler < Azu::Handler::Base
def call(context)
unless authenticated?(context)
context.response.status_code = 401
context.response.print({error: "Unauthorized"}.to_json)
return # Don't call_next - stop here
end
call_next(context)
end
endclass AdminOnlyHandler < Azu::Handler::Base
def call(context)
if admin_route?(context.request.path)
verify_admin!(context)
end
call_next(context)
end
private def admin_route?(path)
path.starts_with?("/admin")
end
endclass MetricsHandler < Azu::Handler::Base
@request_count = Atomic(Int64).new(0)
def call(context)
@request_count.add(1)
call_next(context)
end
def request_count
@request_count.get
end
endclass RateLimitHandler < Azu::Handler::Base
def initialize(@limit : Int32 = 100, @window : Time::Span = 1.minute)
end
def call(context)
if rate_limited?(context)
context.response.status_code = 429
return
end
call_next(context)
end
end
# Usage
RateLimitHandler.new(limit: 200, window: 30.seconds)MyApp.start [
# 1. Error handling (catches everything)
Azu::Handler::Rescuer.new,
# 2. Request ID (for tracing)
RequestIdHandler.new,
# 3. CORS (early for preflight)
CorsHandler.new,
# 4. Logging (after IDs, before auth)
LoggingHandler.new,
# 5. Rate limiting (before expensive ops)
RateLimitHandler.new,
# 6. Authentication
AuthHandler.new,
# 7. Static files (can skip auth)
StaticHandler.new,
# 8. Endpoints
UsersEndpoint.new,
PostsEndpoint.new,
]class Azu::Handler::Rescuer < Base
def call(context)
call_next(context)
rescue ex : Response::Error
render_error(context, ex)
rescue ex
render_internal_error(context, ex)
end
enddescribe AuthHandler do
it "allows authenticated requests" do
context = create_context(headers: {"Authorization" => "Bearer valid"})
handler = AuthHandler.new
handler.call(context)
context.response.status_code.should_not eq(401)
end
it "rejects unauthenticated requests" do
context = create_context
handler = AuthHandler.new
handler.call(context)
context.response.status_code.should eq(401)
end
endrequire "azu"
module MyApp
include Azu
configure do
port = 3000
end
end
struct HelloRequest
include Azu::Request
getter name : String = "World"
end
struct HelloResponse
include Azu::Response
def initialize(@name : String); end
def render
"Hello, #{@name}!"
end
end
struct HelloEndpoint
include Azu::Endpoint(HelloRequest, HelloResponse)
get "/"
def call : HelloResponse
HelloResponse.new(hello_request.name)
end
end
MyApp.start [
Azu::Handler::Logger.new,
Azu::Handler::Rescuer.new
]HTTP Request β Router β Middleware Chain β Endpoint β Response
β
Request Contract (validation)# Ruby - Runtime errors
def show
user = User.find(params[:id])
render json: user.to_josn # Typo: discovered when user hits this endpoint
end# Crystal - Compile-time error
def call
user = User.find(params["id"])
user.to_josn # Error: undefined method 'to_josn' for User
enddef process_order(order, options)
# What is order? What are options?
# Must read implementation to understand
enddef process_order(order : Order, options : ProcessOptions) : OrderResult
# Clear: Order in, ProcessOptions for config, OrderResult out
end# Before
class User
def full_name
"#{first_name} #{last_name}"
end
end
# After - rename to display_name
class User
def display_name
"#{first_name} #{last_name}"
end
end
# Compiler shows every call site that needs updating
# Error: undefined method 'full_name' for Userstruct CreateUserRequest
include Azu::Request
getter name : String # Required, must be string
getter email : String # Required, must be string
getter age : Int32? # Optional, must be integer if present
endstruct UserEndpoint
include Azu::Endpoint(Request, UserResponse)
def call : UserResponse # Must return UserResponse
if condition
UserResponse.new(user)
else
"error" # Compile error: expected UserResponse
end
end
enduser = User.find?(id) # Returns User?
user.name # Error: undefined method 'name' for Nil
if user
user.name # Now compiler knows user is not nil
end# Must declare types
struct UserResponse
include Azu::Response
def initialize(@user : User)
end
end# Ruby metaprogramming
user.send(method_name)# Crystal requires compile-time knowledge
case method_name
when "save"
user.save
when "delete"
user.delete
endclass User
include CQL::Model(User, Int64)
db_context AcmeDB, :users
property id : Int64?
property name : String
property email : String
property created_at : Time?
property updated_at : Time?
def initialize(@name = "", @email = "")
end
end# Rails controller
def create
@user = User.new(user_params)
# What is user_params?
# What fields are allowed?
# What validations apply?
end
private
def user_params
params.require(:user).permit(:name, :email, :age)
# Scattered across the file
# Not type-safe
# Validation elsewhere
endclass NotificationChannel < Azu::Channel
PATH = "/notifications"
CONNECTIONS = [] of HTTP::WebSocket
def on_connect
CONNECTIONS << socket
end
def on_close(code, reason)
CONNECTIONS.delete(socket)
end
def self.broadcast(message : String)
CONNECTIONS.each do |ws|
ws.send(message)
end
end
end# In an endpoint
def call
user = User.create!(create_user_request)
NotificationChannel.broadcast({
type: "user_created",
user: {id: user.id, name: user.name}
}.to_json)
UserResponse.new(user)
endmodule MyApp
include Azu
endstruct MyEndpoint
include Azu::Endpoint(RequestType, ResponseType)
end# Using Homebrew (recommended)
brew install crystal-lang
# Verify installation
crystal version
# Should output: Crystal 1.x.x# Add Crystal repository
curl -fsSL https://crystal-lang.org/install.sh | sudo bash
# Install Crystal
sudo apt-get install crystal
# Verify installation
crystal version# Add Crystal repository
curl -fsSL https://crystal-lang.org/install.sh | sudo bash
# Fedora
sudo dnf install crystal
# CentOS/RHEL
sudo yum install crystal
# Verify installation
crystal version# Using Chocolatey
choco install crystal
# Using Scoop
scoop install crystal
# Verify installation
crystal version# Create project directory
crystal init app my_first_azu_app
cd my_first_azu_appname: my_first_azu_app
version: 0.1.0
authors:
- Your Name <you@example.com>
dependencies:
azu:
github: azutoolkit/azu
version: ~> 0.5.28
crystal: >= 0.35.0
license: MITshards installrequire "azu"
# Define your application module
module MyFirstAzuApp
include Azu
configure do
port = 4000
host = "0.0.0.0"
end
end
# Create a simple endpoint
struct HelloEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Text)
get "/"
def call
text "Hello from Azu!"
end
end
# Create a JSON endpoint
struct GreetEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Json)
get "/greet/:name"
def call
json({
message: "Hello, #{params["name"]}!",
timestamp: Time.utc.to_rfc3339
})
end
end
# Start the server
MyFirstAzuApp.start [
HelloEndpoint.new,
GreetEndpoint.new,
]crystal run src/my_first_azu_app.crServer started at Fri 01/24/2026 10:30:45.
β€ Environment: development
β€ Host: 0.0.0.0
β€ Port: 4000
β€ Startup Time: 12.34 millis# Test the hello endpoint
curl http://localhost:4000/
# Output: Hello from Azu!
# Test the greet endpoint
curl http://localhost:4000/greet/World
# Output: {"message":"Hello, World!","timestamp":"2026-01-24T10:30:45Z"}module MyFirstAzuApp
include Azu
configure do
port = 4000
host = "0.0.0.0"
end
endstruct HelloEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Text)
get "/"
def call
text "Hello from Azu!"
end
endget "/greet/:name"
def call
params["name"] # Access route parameters
endmy_first_azu_app/
βββ shard.yml # Dependencies
βββ shard.lock # Locked versions
βββ src/
β βββ my_first_azu_app.cr # Main application
βββ lib/ # Installed dependencies
βββ spec/ # Test filesexport PATH="/usr/local/bin:$PATH"
echo 'export PATH="/usr/local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrcrm -rf lib/ shard.lock
shards installlsof -i :4000
kill -9 <PID>class User
include CQL::Model(User, Int64)
db_context AcmeDB, :users
property id : Int64?
property name : String
property email : String
property age : Int32?
def initialize(@name = "", @email = "", @age = nil)
end
validate name, presence: true, length: {min: 2, max: 100}
validate email, presence: true, format: /@/, uniqueness: true
validate age, numericality: {greater_than: 0}, allow_nil: true
endclass Post
include CQL::Model(Post, Int64)
db_context AcmeDB, :posts
property id : Int64?
property user_id : Int64
property title : String
property content : String
belongs_to :user, User, foreign_key: :user_id
endclass User
include CQL::Model(User, Int64)
db_context AcmeDB, :users
property id : Int64?
property name : String
property email : String
has_many :posts, Post, foreign_key: :user_id
has_many :comments, Comment, foreign_key: :user_id
endclass User
include CQL::Model(User, Int64)
db_context AcmeDB, :users
property id : Int64?
property name : String
has_one :profile, Profile, foreign_key: :user_id
endclass Post
include CQL::Model(Post, Int64)
db_context AcmeDB, :posts
property id : Int64?
property title : String
has_many :post_tags, PostTag, foreign_key: :post_id
has_many :tags, Tag, through: :post_tags
end
class Tag
include CQL::Model(Tag, Int64)
db_context AcmeDB, :tags
property id : Int64?
property name : String
has_many :post_tags, PostTag, foreign_key: :tag_id
has_many :posts, Post, through: :post_tags
end
class PostTag
include CQL::Model(PostTag, Int64)
db_context AcmeDB, :post_tags
property id : Int64?
property post_id : Int64
property tag_id : Int64
belongs_to :post, Post
belongs_to :tag, Tag
endclass User
include CQL::Model(User, Int64)
db_context AcmeDB, :users
property id : Int64?
property email : String
property normalized_email : String?
before_save :normalize_email
after_create :send_welcome_email
before_destroy :cleanup_associations
private def normalize_email
@normalized_email = email.downcase.strip
end
private def send_welcome_email
Mailer.welcome(self).deliver
end
private def cleanup_associations
posts.each(&.destroy)
end
endclass Post
include CQL::Model(Post, Int64)
db_context AcmeDB, :posts
property id : Int64?
property title : String
property published : Bool
property created_at : Time?
scope :published, -> { where(published: true) }
scope :recent, -> { order(created_at: :desc) }
scope :by_user, ->(user_id : Int64) { where(user_id: user_id) }
end
# Usage
Post.published.recent.all
Post.by_user(user.id).published.allclass User
include CQL::Model(User, Int64)
db_context AcmeDB, :users
property id : Int64?
property name : String
property email : String
property password_hash : String?
def full_name
name
end
def password=(plain_password : String)
@password_hash = Crypto::Bcrypt::Password.create(plain_password).to_s
end
def authenticate(plain_password : String) : Bool
return false unless hash = password_hash
Crypto::Bcrypt::Password.new(hash).verify(plain_password)
end
def recent_posts(limit = 10)
Post.where(user_id: id).order(created_at: :desc).limit(limit).all
end
endclass User
include CQL::Model(User, Int64)
include JSON::Serializable
db_context AcmeDB, :users
property id : Int64?
property name : String
property email : String
@[JSON::Field(ignore: true)]
property password_hash : String?
end
# Serialize to JSON
user.to_json # {"id": 1, "name": "Alice", "email": "alice@example.com"}struct CreateUserRequest
include Azu::Request
getter name : String
getter email : String
getter age : Int32?
validate name, presence: true, length: {min: 2}
validate email, presence: true, format: /@/
endstruct SearchProductsRequest
include Azu::Request
getter query : String # Required search term
getter category : String? # Optional filter
getter min_price : Float64? # Optional minimum
getter max_price : Float64? # Optional maximum
getter page : Int32 = 1 # Default: first page
getter per_page : Int32 = 20 # Default: 20 items
endstruct ProductSearchResponse
include Azu::Response
def initialize(
@products : Array(Product),
@total : Int64,
@page : Int32
)
end
def render
{
data: @products.map { |p| serialize(p) },
meta: {total: @total, page: @page}
}.to_json
end
endstruct CreateOrderRequest
include Azu::Request
getter items : Array(OrderItem)
getter shipping_address : String
getter payment_method : String
# Validation right here with the fields
validate items, presence: true
validate shipping_address, presence: true
validate payment_method, inclusion: {in: ["card", "paypal"]}
# Custom validation in the same struct
def validate
super
errors << Error.new(:items, "too many") if items.size > 100
end
enddef call : OrderResponse
# Type is known: Array(OrderItem)
items = create_order_request.items
items.each do |item|
# Type is known: OrderItem
process(item.product_id, item.quantity)
end
end# What does this endpoint accept?
struct CreateUserEndpoint
include Azu::Endpoint(CreateUserRequest, UserResponse)
# β Look here β Look here
endmodule V1
struct UserResponse
include Azu::Response
# V1 fields
end
end
module V2
struct UserResponse
include Azu::Response
# V2 fields (breaking changes)
end
end
# Different endpoints use different versions
struct V1::UsersEndpoint
include Azu::Endpoint(Request, V1::UserResponse)
end
struct V2::UsersEndpoint
include Azu::Endpoint(Request, V2::UserResponse)
enddescribe CreateUserRequest do
it "validates presence of name" do
request = CreateUserRequest.new(name: "", email: "a@b.com")
request.valid?.should be_false
request.errors.map(&.field).should contain(:name)
end
it "validates email format" do
request = CreateUserRequest.new(name: "Alice", email: "invalid")
request.valid?.should be_false
end
end# Generate OpenAPI spec from contracts
OpenAPI.generate(CreateUserRequest)
# => {
# type: "object",
# required: ["name", "email"],
# properties: {
# name: {type: "string", minLength: 2},
# email: {type: "string", format: "email"}
# }
# }# Extra struct definition needed
struct MyRequest
include Azu::Request
getter field : String
end# Ruby: Accept any fields
params.permit!# Crystal: Must define all fields
# Can use JSON::Any for truly dynamic dataclass ChatChannel < Azu::Channel
PATH = "/chat"
CONNECTIONS = [] of HTTP::WebSocket
def on_message(message : String)
# Broadcast to everyone except sender
CONNECTIONS.each do |ws|
ws.send(message) unless ws == socket
end
end
endclass RoomChannel < Azu::Channel
PATH = "/rooms/:room_id"
@@rooms = Hash(String, Set(HTTP::WebSocket)).new { |h, k| h[k] = Set(HTTP::WebSocket).new }
def on_connect
room_id = params["room_id"]
@@rooms[room_id] << socket
end
def on_close(code, reason)
room_id = params["room_id"]
@@rooms[room_id].delete(socket)
end
def self.broadcast_to(room_id : String, message : String)
@@rooms[room_id].each(&.send(message))
end
def self.broadcast_to_all(message : String)
@@rooms.each_value do |sockets|
sockets.each(&.send(message))
end
end
endclass UserChannel < Azu::Channel
PATH = "/user"
@@user_sockets = Hash(Int64, Set(HTTP::WebSocket)).new { |h, k| h[k] = Set(HTTP::WebSocket).new }
def on_connect
if user = authenticate
@@user_sockets[user.id] << socket
end
end
def on_close(code, reason)
if user = @current_user
@@user_sockets[user.id].delete(socket)
end
end
def self.send_to_user(user_id : Int64, message : String)
@@user_sockets[user_id].each(&.send(message))
end
def self.send_to_users(user_ids : Array(Int64), message : String)
user_ids.each { |id| send_to_user(id, message) }
end
endclass FilteredChannel < Azu::Channel
PATH = "/feed"
record Connection, socket : HTTP::WebSocket, topics : Set(String)
@@connections = [] of Connection
def on_connect
topics = params["topics"]?.try(&.split(",").to_set) || Set(String).new
@@connections << Connection.new(socket, topics)
end
def self.broadcast(topic : String, message : String)
@@connections.each do |conn|
if conn.topics.includes?(topic)
conn.socket.send(message)
end
end
end
enddef self.broadcast_async(message : String)
spawn do
CONNECTIONS.each do |ws|
begin
ws.send(message)
rescue
# Handle disconnected socket
end
end
end
end# Background job
class OrderProcessor
def process(order_id : Int64)
order = Order.find!(order_id)
order.process!
# Notify the user
UserChannel.send_to_user(order.user_id, {
type: "order_updated",
order_id: order.id,
status: order.status
}.to_json)
end
endclass ThrottledChannel < Azu::Channel
@@last_broadcast = Time.utc
@@min_interval = 100.milliseconds
def self.broadcast(message : String)
now = Time.utc
return if now - @@last_broadcast < @@min_interval
@@last_broadcast = now
CONNECTIONS.each(&.send(message))
end
endclass BatchedChannel < Azu::Channel
@@pending_messages = [] of String
@@flush_scheduled = false
def self.queue(message : String)
@@pending_messages << message
schedule_flush unless @@flush_scheduled
end
private def self.schedule_flush
@@flush_scheduled = true
spawn do
sleep 50.milliseconds
flush
end
end
private def self.flush
return if @@pending_messages.empty?
batch = {type: "batch", messages: @@pending_messages}.to_json
CONNECTIONS.each(&.send(batch))
@@pending_messages.clear
@@flush_scheduled = false
end
endAzu.configure do |config|
config.port = 8080
config.host = "0.0.0.0"
config.env = Environment::Production
config.template_hot_reload = false
endenum Environment
Development
Test
Production
endif Azu.env.production?
# Production-only code
end
Azu.env.development? # => Bool
Azu.env.test? # => BoolMyApp.start [
Azu::Handler::Rescuer.new,
Azu::Handler::Logger.new,
MyEndpoint.new,
]MyApp.start do |server|
server.bind_tcp("0.0.0.0", 8080)
server.listen
endAzu.cache.set("key", "value", expires_in: 1.hour)
Azu.cache.get("key") # => "value"
Azu.cache.delete("key")Azu.router.routes # => Array of registered routesAzu.log.info { "Application started" }
Azu.log.error(exception: ex) { "Error occurred" }alias EmptyRequest = Azu::Request::Empty
alias Params = Hash(String, String)VERSION = "0.5.28"get "/path"
get "/users/:id"
get "/search"post "/users"
post "/login"put "/users/:id"patch "/users/:id"delete "/users/:id"options "/users"head "/users/:id"def call : ResponseType
# Handle request and return response
enddef call
id = params["id"] # Route parameter
page = params["page"]? # Optional query parameter
enddef call
auth = headers["Authorization"]?
content_type = headers["Content-Type"]?
enddef call
context.request # HTTP::Request
context.response # HTTP::Server::Response
enddef call
method = request.method
path = request.path
body = request.body
enddef call
response.headers["X-Custom"] = "value"
response.status_code = 201
enddef call
status 201 # Created
status 204 # No Content
endstruct CreateUserEndpoint
include Azu::Endpoint(CreateUserRequest, UserResponse)
post "/users"
def call : UserResponse
# Access request via snake_case method name
create_user_request.name
create_user_request.email
end
enddef call
json({message: "Hello", count: 42})
enddef call
text "Hello, World!"
enddef call
html "<h1>Hello</h1>"
enddef call
redirect_to "/dashboard"
redirect_to "/login", status: 301 # Permanent
endget "/users/:id"
get "/users/:user_id/posts/:post_id"
def call
user_id = params["user_id"]
post_id = params["post_id"]
endget "/files/*path"
def call
path = params["path"] # Captures rest of path
endstruct UserEndpoint
include Azu::Endpoint(EmptyRequest, UserResponse)
get "/users/:id"
def call : UserResponse
user_id = params["id"].to_i64
user = User.find?(user_id)
unless user
raise Azu::Response::NotFound.new("/users/#{user_id}")
end
UserResponse.new(user)
end
end
struct UserResponse
include Azu::Response
def initialize(@user : User)
end
def render
{
id: @user.id,
name: @user.name,
email: @user.email
}.to_json
end
endclass NotificationChannel < Azu::Channel
CONNECTIONS = Set(HTTP::WebSocket).new
ws "/notifications"
def on_connect
# Add socket to connections
CONNECTIONS << socket.not_nil!
# Send welcome message
send_to_client({
type: "connected",
message: "Connected to notifications",
timestamp: Time.utc.to_rfc3339
})
Log.info { "Client connected. Total: #{CONNECTIONS.size}" }
end
def on_message(message : String)
begin
data = JSON.parse(message)
handle_message(data)
rescue JSON::ParseException
send_to_client({type: "error", message: "Invalid JSON"})
end
end
def on_close(code, message)
CONNECTIONS.delete(socket)
Log.info { "Client disconnected. Total: #{CONNECTIONS.size}" }
end
private def handle_message(data : JSON::Any)
case data["type"]?.try(&.as_s)
when "ping"
send_to_client({type: "pong", timestamp: Time.utc.to_rfc3339})
when "subscribe"
send_to_client({type: "subscribed", message: "Subscribed to notifications"})
else
send_to_client({type: "error", message: "Unknown message type"})
end
end
private def send_to_client(data)
socket.not_nil!.send(data.to_json)
end
# Broadcast to all connected clients
def self.broadcast(message)
CONNECTIONS.each do |socket|
spawn socket.send(message.to_json)
end
end
endstruct CreateUserEndpoint
include Azu::Endpoint(CreateUserRequest, UserResponse)
post "/users"
def call : UserResponse
unless create_user_request.valid?
raise Azu::Response::ValidationError.new(
create_user_request.errors.group_by(&.field).transform_values(&.map(&.message))
)
end
if User.find_by_email(create_user_request.email)
raise Azu::Response::ValidationError.new(
{"email" => ["Email is already taken"]}
)
end
user = User.new(
name: create_user_request.name,
email: create_user_request.email,
age: create_user_request.age
)
# Broadcast to all WebSocket clients
NotificationChannel.broadcast({
type: "user_created",
user: {
id: user.id,
name: user.name,
email: user.email
},
timestamp: Time.utc.to_rfc3339
})
status 201
UserResponse.new(user)
end
endrequire "azu"
require "./models/*"
require "./requests/*"
require "./responses/*"
require "./endpoints/*"
require "./channels/*"
module UserAPI
include Azu
configure do
port = ENV.fetch("PORT", "4000").to_i
host = ENV.fetch("HOST", "0.0.0.0")
end
end
UserAPI.start [
Azu::Handler::RequestId.new,
Azu::Handler::Rescuer.new,
Azu::Handler::Logger.new,
Azu::Handler::CORS.new,
NotificationChannel.new, # Add the channel
ListUsersEndpoint.new,
ShowUserEndpoint.new,
CreateUserEndpoint.new,
UpdateUserEndpoint.new,
DeleteUserEndpoint.new,
]<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Real-time Notifications</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
#notifications {
border: 1px solid #ddd;
padding: 10px;
margin: 20px 0;
max-height: 300px;
overflow-y: auto;
}
.notification {
padding: 5px;
border-bottom: 1px solid #eee;
}
.connected { color: green; }
.disconnected { color: red; }
</style>
</head>
<body>
<h1>Real-time Notifications</h1>
<div id="status" class="disconnected">Disconnected</div>
<div id="notifications">
<h3>Events</h3>
</div>
<script>
const statusEl = document.getElementById('status');
const notificationsEl = document.getElementById('notifications');
// Connect to WebSocket
const ws = new WebSocket('ws://localhost:4000/notifications');
ws.onopen = function() {
statusEl.textContent = 'Connected';
statusEl.className = 'connected';
// Subscribe to notifications
ws.send(JSON.stringify({ type: 'subscribe' }));
};
ws.onmessage = function(event) {
const data = JSON.parse(event.data);
addNotification(data);
};
ws.onclose = function() {
statusEl.textContent = 'Disconnected';
statusEl.className = 'disconnected';
};
function addNotification(data) {
const div = document.createElement('div');
div.className = 'notification';
const time = new Date().toLocaleTimeString();
switch(data.type) {
case 'user_created':
div.textContent = `${time}: User created - ${data.user.name}`;
break;
case 'user_updated':
div.textContent = `${time}: User updated - ${data.user.name}`;
break;
case 'user_deleted':
div.textContent = `${time}: User deleted - ID ${data.user_id}`;
break;
case 'connected':
case 'subscribed':
div.textContent = `${time}: ${data.message}`;
break;
default:
div.textContent = `${time}: ${JSON.stringify(data)}`;
}
notificationsEl.appendChild(div);
}
</script>
</body>
</html>UserAPI.start [
Azu::Handler::RequestId.new,
Azu::Handler::Rescuer.new,
Azu::Handler::Logger.new,
Azu::Handler::CORS.new,
Azu::Handler::Static.new("public"), # Serve static files
NotificationChannel.new,
# ... endpoints
]crystal run src/user_api.crcurl -X POST http://localhost:4000/users \
-H "Content-Type: application/json" \
-d '{"name": "Test User", "email": "test@example.com"}'class ChatChannel < Azu::Channel
@@rooms = Hash(String, Set(HTTP::WebSocket)).new { |h, k| h[k] = Set(HTTP::WebSocket).new }
@@socket_rooms = Hash(HTTP::WebSocket, String).new
ws "/chat"
def on_connect
send_to_client({
type: "connected",
message: "Send a 'join' message with room_id to join a room"
})
end
def on_message(message : String)
data = JSON.parse(message)
case data["type"]?.try(&.as_s)
when "join"
handle_join(data)
when "message"
handle_chat_message(data)
end
rescue
send_to_client({type: "error", message: "Invalid message"})
end
def on_close(code, message)
if room_id = @@socket_rooms[socket]?
@@rooms[room_id].delete(socket)
@@socket_rooms.delete(socket)
end
end
private def handle_join(data)
room_id = data["room_id"]?.try(&.as_s)
return send_to_client({type: "error", message: "room_id required"}) unless room_id
@@socket_rooms[socket] = room_id
@@rooms[room_id] << socket
send_to_client({type: "joined", room_id: room_id})
# Notify others in room
broadcast_to_room(room_id, {
type: "user_joined",
message: "A user joined the room"
}, exclude: socket)
end
private def handle_chat_message(data)
room_id = @@socket_rooms[socket]?
return send_to_client({type: "error", message: "Not in a room"}) unless room_id
message = data["message"]?.try(&.as_s)
return send_to_client({type: "error", message: "Message required"}) unless message
broadcast_to_room(room_id, {
type: "chat_message",
message: message,
timestamp: Time.utc.to_rfc3339
})
end
private def broadcast_to_room(room_id : String, data, exclude : HTTP::WebSocket? = nil)
message = data.to_json
@@rooms[room_id].each do |connection|
next if connection == exclude
spawn { connection.send(message) }
end
end
private def send_to_client(data)
socket.send(data.to_json)
end
enddef on_connect # Called when client connects
def on_message # Called when message received
def on_close # Called when client disconnects# To all clients
CONNECTIONS.each { |s| spawn s.send(msg) }
# To specific room
@@rooms[room_id].each { |s| spawn s.send(msg) }
# Exclude sender
next if connection == exclude{"type": "join", "room_id": "general"}
{"type": "message", "content": "Hello!"}require "cql"
require "azu"
# Define schema
AppDB = CQL::Schema.define(:app, adapter: CQL::Adapter::Postgres, uri: ENV["DATABASE_URL"]) do
table :users do
primary :id, Int64
text :name
text :email
timestamps
end
end
# Define model
struct User
include CQL::ActiveRecord::Model(Int64)
db_context AppDB, :users
getter id : Int64?
getter name : String
getter email : String
end
# Use in endpoint
struct UserEndpoint
include Azu::Endpoint(EmptyRequest, UserResponse)
get "/users/:id"
def call : UserResponse
user = User.find(params["id"].to_i64)
UserResponse.new(user)
end
endclass AuthenticationHandler
include HTTP::Handler
def call(context)
token = context.request.headers["Authorization"]?
unless token && valid_token?(token)
context.response.status = HTTP::Status::UNAUTHORIZED
context.response.print "Authentication required"
return
end
context.set("current_user", get_user_from_token(token))
call_next(context)
end
end
# Add to middleware stack
MyApp.start [
AuthenticationHandler.new,
# ... other handlers
]# Build for production
crystal build --release --no-debug src/my_app.cr
# Deploy binary to server
scp my_app user@server:/opt/myapp/# β This causes type inference issues
def create_user(request)
# Crystal can't infer the request type
end
# β
Use explicit types
def create_user(request : CreateUserRequest) : UserResponse
# Clear type information
endError: can't find template "users/show.html"configure do
templates.path = ["templates", "views"] # Add all template directories
template_hot_reload = env.development? # Enable hot reload for development
endclass MyChannel < Azu::Channel
ws "/websocket" # Make sure this matches client URL
def on_connect
# Implementation
end
endMyApp.start [
Azu::Handler::Logger.new,
# Don't put static handler before WebSocket routes
# WebSocket handlers should come early in the stack
]// Make sure URL matches server route
const ws = new WebSocket("ws://localhost:4000/websocket");def call : UserResponse
# Always validate before processing
unless create_user_request.valid?
raise Azu::Response::ValidationError.new(
create_user_request.errors.group_by(&.field).transform_values(&.map(&.message))
)
end
# Process validated request
UserResponse.new(create_user(create_user_request))
endstruct UserRequest
include Azu::Request
getter name : String
getter email : String
# β
Correct validation syntax
validate name, presence: true, length: {min: 2}
validate email, presence: true, format: /@/
def initialize(@name = "", @email = "")
end
endconfigure do
upload.max_file_size = 50.megabytes
upload.temp_dir = "/tmp/uploads"
endstruct FileUploadRequest
include Azu::Request
getter file : Azu::Params::Multipart::File?
getter description : String
def initialize(@file = nil, @description = "")
end
end
def call : FileUploadResponse
if file = file_upload_request.file
# Validate file
raise error("File too large") if file.size > 10.megabytes
# Save file
final_path = save_uploaded_file(file)
file.cleanup # Important: clean up temp file
FileUploadResponse.new(path: final_path)
else
raise error("File is required")
end
endMyApp.start [
Azu::Handler::Logger.new, # Add this first
# ... other handlers
]def call : UserResponse
# β Blocking database call
users = database.query("SELECT * FROM users") # Blocks fiber
# β
Use async operations when possible
users = database.async_query("SELECT * FROM users")
UserResponse.new(users)
enddef handle_file_upload(file)
process_file(file)
ensure
file.cleanup if file # Always cleanup
endclass MyChannel < Azu::Channel
CONNECTIONS = Set(HTTP::WebSocket).new
def on_connect
CONNECTIONS << socket.not_nil!
end
def on_close(code, message)
CONNECTIONS.delete(socket) # Remove on disconnect
end
endconfigure do
template_hot_reload = env.development? # Make sure this is true
end# Make sure template files are readable
chmod -R 644 templates/MyApp.start [
Azu::Handler::CORS.new(
allowed_origins: ["http://localhost:3000"], # Add your frontend URL
allowed_methods: %w(GET POST PUT PATCH DELETE OPTIONS),
allowed_headers: %w(Accept Content-Type Authorization)
),
# ... other handlers
]configure do
log.level = Log::Severity::DEBUG # See all log messages
enddef call : UserResponse
Log.debug { "Request data: #{create_user_request.inspect}" }
Log.debug { "Params: #{params.to_hash}" }
Log.debug { "Headers: #{context.request.headers}" }
# ... endpoint logic
end# Add pp for pretty printing
require "pp"
def call : UserResponse
pp create_user_request # Pretty print request object
pp params.to_hash # Pretty print parameters
# ... endpoint logic
end# Test request contracts in isolation
user_request = CreateUserRequest.new(name: "test", email: "test@example.com")
puts user_request.valid?
puts user_request.errors.map(&.message)
# Test response objects
user = User.new(name: "test")
response = UserResponse.new(user)
puts response.renderrequire "benchmark"
def call : UserResponse
time = Benchmark.measure do
# Your endpoint logic here
end
Log.info { "Endpoint took #{time.total_seconds}s" }
# ... return response
end# Check memory usage during runtime
ps aux | grep my_app
# Use Crystal's built-in memory tracking
crystal run --stats src/my_app.cr# Log slow queries
def call : UserResponse
start_time = Time.instant
users = database.query("SELECT * FROM users WHERE active = true")
duration = Time.instant - start_time
if duration > 100.milliseconds
Log.warn { "Slow query detected: #{duration.total_milliseconds}ms" }
end
UserResponse.new(users)
end**Crystal Version**: 1.10.1
**Azu Version**: 0.5.26
**OS**: macOS 13.0
**Issue**: WebSocket connection fails with "Connection refused"
**Reproduction**:
```crystal
class TestChannel < Azu::Channel
ws "/test"
def on_connect
puts "Connected"
end
end
---
**Still need help?** Check the [Contributing Guide](contributing/setup.md) for information on getting support from the community.# Check your system requirements
echo "Operating System: $(uname -s)"
echo "Architecture: $(uname -m)"
echo "Available Memory: $(free -h | grep Mem | awk '{print $2}')"
echo "Available Disk Space: $(df -h . | tail -1 | awk '{print $4}')"# Install Crystal on macOS
brew install crystal
# Install Crystal on Ubuntu/Debian
curl -fsSL https://crystal-lang.org/install.sh | sudo bash
# Install Crystal on CentOS/RHEL
curl -fsSL https://crystal-lang.org/install.sh | sudo bash
# Verify installation
crystal --version# Configure Git
git config --global user.name "Your Name"
git config --global user.email "your.email@example.com"
# Set up SSH key (optional but recommended)
ssh-keygen -t ed25519 -C "your.email@example.com"
cat ~/.ssh/id_ed25519.pub
# Add to GitHub/GitLab# Fork the repository on GitHub
# Then clone your fork
git clone https://github.com/your-username/azu.git
cd azu
# Add upstream remote
git remote add upstream https://github.com/azu-framework/azu.git
# Verify remotes
git remote -v# Create a feature branch
git checkout -b feature/your-feature-name
# Or create a bugfix branch
git checkout -b fix/your-bug-description
# Or create a documentation branch
git checkout -b docs/your-doc-update// .vscode/settings.json
{
"crystal.formatOnSave": true,
"crystal.languageServer": true,
"crystal.compiler": "crystal",
"editor.formatOnSave": true,
"editor.rulers": [120],
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true
}// .vscode/extensions.json
{
"recommendations": [
"crystal-lang-tools.crystal-lang",
"ms-vscode.vscode-json",
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode"
]
}# Install Crystal language server
crystal tool install
# Verify language server
crystal tool --help# Install project dependencies
shards install
# Verify installation
crystal spec# shard.yml development dependencies
development_dependencies:
ameba:
github: crystal-ameba/ameba
version: ~> 1.5.0
db:
github: crystal-lang/crystal-db
version: ~> 0.12.0
sqlite3:
github: crystal-lang/crystal-sqlite3
version: ~> 0.20.0# Install Ameba for code analysis
crystal tool install ameba
# Run Ameba
crystal tool run ameba
# Run Ameba on specific files
crystal tool run ameba src/azu/# Run all tests
crystal spec
# Run specific test file
crystal spec spec/azu/endpoint_spec.cr
# Run tests with coverage
crystal spec --coverage
# Run tests in parallel
crystal spec --parallel# Generate documentation
crystal docs
# Serve documentation locally
crystal docs --serve
# Generate API documentation
crystal docs src/azu.cr# Install PostgreSQL (macOS)
brew install postgresql
brew services start postgresql
# Install PostgreSQL (Ubuntu)
sudo apt-get install postgresql postgresql-contrib
sudo systemctl start postgresql
# Create development database
createdb azu_development
createdb azu_test# config/database.cr
CONFIG.database = {
development: {
url: "postgresql://localhost/azu_development",
pool_size: 5
},
test: {
url: "postgresql://localhost/azu_test",
pool_size: 2
}
}# .editorconfig
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.cr]
indent_size = 2
[*.yml]
indent_size = 2
[*.md]
trim_trailing_whitespace = false# .git/hooks/pre-commit
#!/bin/bash
# Run tests
crystal spec
# Run code analysis
crystal tool run ameba
# Check formatting
crystal tool format --check# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:13
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: azu_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- name: Setup Crystal
uses: crystal-lang/install-crystal@v1
- name: Install dependencies
run: shards install
- name: Run tests
run: crystal spec
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/azu_test
- name: Run code analysis
run: crystal tool run ameba// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Azu",
"type": "crystal",
"request": "launch",
"program": "${workspaceFolder}/src/azu.cr",
"args": [],
"cwd": "${workspaceFolder}"
},
{
"name": "Debug Tests",
"type": "crystal",
"request": "launch",
"program": "crystal",
"args": ["spec"],
"cwd": "${workspaceFolder}"
}
]
}# config/logging.cr
require "log"
# Development logging
Log.setup do |c|
backend = Log::IOBackend.new
c.bind("*", :info, backend)
c.bind("azu.*", :debug, backend)
end# Install profiling tools
crystal tool install profile
# Run with profiling
crystal run --profile src/azu.cr
# Memory profiling
crystal run --stats src/azu.cr# spec/benchmark/performance_spec.cr
require "benchmark"
describe "Performance Benchmarks" do
it "benchmarks endpoint performance" do
time = Benchmark.measure do
1000.times do
# Test endpoint
end
end
puts "Average time: #{time.real / 1000}ms"
end
end# Install MkDocs
pip install mkdocs mkdocs-material
# Serve documentation
mkdocs serve
# Build documentation
mkdocs build# mkdocs.yml
site_name: Azu Framework
theme:
name: material
features:
- navigation.tabs
- navigation.sections
- navigation.expand
- search.highlight
nav:
- Overview: index.md
- Getting Started:
- Installation: getting-started/installation.md
- First App: getting-started/first-app.md
- Core Concepts:
- Endpoints: core-concepts/endpoints.md
- Requests: core-concepts/requests.md
- Responses: core-concepts/responses.md# spec/spec_helper.cr
require "spec"
require "../src/azu"
# Test configuration
CONFIG.test = {
database_url: "sqlite3://./test.db",
log_level: "error",
environment: "test"
}
# Test utilities
module TestHelpers
def self.create_test_request(path : String, method : String = "GET")
Azu::HttpRequest.new(
method: method,
path: path,
params: {} of String => String,
headers: HTTP::Headers.new
)
end
end# spec/database_helper.cr
module DatabaseHelper
def self.setup_test_database
# Create test database schema
DB.connect(CONFIG.test.database_url) do |db|
db.exec("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)")
end
end
def self.cleanup_test_database
# Clean test data
DB.connect(CONFIG.test.database_url) do |db|
db.exec("DELETE FROM users")
end
end
end#!/bin/bash
# scripts/build.sh
echo "Building Azu framework..."
# Clean previous build
rm -rf bin/
mkdir -p bin/
# Build release version
crystal build --release src/azu.cr -o bin/azu
# Build debug version
crystal build --debug src/azu.cr -o bin/azu-debug
echo "Build completed!"#!/bin/bash
# scripts/dev-server.sh
echo "Starting development server..."
# Run with hot reload
crystal run --watch src/azu.cr
echo "Development server stopped."#!/bin/bash
# scripts/quality.sh
echo "Running code quality checks..."
# Format code
crystal tool format
# Run Ameba
crystal tool run ameba
# Run tests
crystal spec
echo "Code quality checks completed!"# Issue: Crystal not found
export PATH="/usr/local/bin:$PATH"
# Issue: Permission denied
sudo chown -R $(whoami) /usr/local/bin
# Issue: Database connection failed
sudo systemctl start postgresql
# Issue: Dependencies not found
shards install --frozen# Check Crystal installation
crystal --version
which crystal
# Check dependencies
shards list
# Check database connection
psql -h localhost -U postgres -d azu_development
# Check system resources
top
df -h
free -h# 1. Fork the repository
# 2. Clone your fork
git clone https://github.com/your-username/azu.git
# 3. Create a feature branch
git checkout -b feature/your-feature
# 4. Make your changes
# 5. Run tests
crystal spec
# 6. Commit your changes
git add .
git commit -m "Add feature: description"
# 7. Push to your fork
git push origin feature/your-feature
# 8. Create a pull request# Follow the established structure
src/
βββ azu/
β βββ handler/
β βββ templates/
β βββ *.cr
βββ azu.cr
βββ main.cr# Write tests for new features
describe "New Feature" do
it "works as expected" do
# Test implementation
end
it "handles edge cases" do
# Edge case testing
end
end# Document public APIs
# Represents a user in the system
class User
# Creates a new user with the given attributes
def initialize(@name : String, @email : String)
end
endstruct MyResponse
include Azu::Response
def initialize(@data : MyData)
end
def render
@data.to_json
end
enddef render : String
{id: @user.id, name: @user.name}.to_json
endstruct MyEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Json)
get "/data"
def call
json({message: "Hello", count: 42})
end
endstruct HealthEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Text)
get "/health"
def call
text "OK"
end
endstruct PageEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Html)
get "/page"
def call
html "<h1>Welcome</h1>"
end
endstruct DeleteEndpoint
include Azu::Endpoint(EmptyRequest, Azu::Response::Empty)
delete "/items/:id"
def call
Item.find(params["id"]).destroy
status 204
Azu::Response::Empty.new
end
endclass Azu::Response::Error < Exception
getter status : Int32
getter message : String
def initialize(@message : String, @status : Int32 = 500)
end
enddef call
user = User.find?(params["id"])
raise Azu::Response::NotFound.new("/users/#{params["id"]}") unless user
UserResponse.new(user)
endraise Azu::Response::ValidationError.new([
{field: "email", message: "is invalid"},
{field: "name", message: "is required"}
])struct UserResponse
include Azu::Response
def initialize(@user : User)
end
def render
{
id: @user.id,
name: @user.name,
email: @user.email,
created_at: @user.created_at.try(&.to_rfc3339)
}.to_json
end
end
struct UsersResponse
include Azu::Response
def initialize(
@users : Array(User),
@page : Int32,
@total : Int64
)
end
def render
{
data: @users.map { |u| serialize_user(u) },
meta: {
page: @page,
total: @total
}
}.to_json
end
private def serialize_user(user : User)
{id: user.id, name: user.name}
end
enddef call
response.headers["X-Custom-Header"] = "value"
response.headers["Cache-Control"] = "max-age=3600"
MyResponse.new(data)
enddef call
status 201 # Created
UserResponse.new(user)
end
def call
status 202 # Accepted
TaskResponse.new(task)
enddef call
response.content_type = "application/json"
response.headers["Transfer-Encoding"] = "chunked"
response.print "["
users.each_with_index do |user, i|
response.print "," if i > 0
response.print user.to_json
response.flush
end
response.print "]"
enddef call
case accept_type
when "application/json"
json(data)
when "text/html"
html(render_html(data))
when "text/plain"
text(data.to_s)
else
json(data)
end
end
private def accept_type
headers["Accept"]?.try(&.split(",").first) || "application/json"
end# Ruby/Rails - These errors happen at runtime
def create
user = User.create(params[:user])
render json: user.to_josn # Typo not caught until runtime
end# This won't compile - typo caught immediately
user.to_josn # Error: undefined method 'to_josn' for User
# Correct
user.to_jsonstruct CreateUserEndpoint
include Azu::Endpoint(CreateUserRequest, UserResponse)
# β Input type β Output type
def call : UserResponse # β Must return this type
# ...
end
enddef call : UserResponse
if user = find_user
UserResponse.new(user)
else
"Not found" # Error: expected UserResponse, got String
end
endstruct CreateUserRequest
include Azu::Request
getter name : String # Required string
getter email : String # Required string
getter age : Int32? # Optional integer
enddef call : UserResponse
# Type is known - no nil checks needed
name = create_user_request.name # String, not String?
# Optional fields are nil-able
if age = create_user_request.age
validate_age(age)
end
# ...
endstruct UserResponse
include Azu::Response
def initialize(@user : User)
end
def render
{
id: @user.id, # Int64
name: @user.name, # String
email: @user.email # String
}.to_json
end
endget "/users/:id"
def call : UserResponse
# params["id"] is String
id = params["id"].to_i64 # Explicit conversion to Int64
user = User.find(id)
UserResponse.new(user)
enddef call : UserResponse
user = User.find?(params["id"])
# user is User? (might be nil)
user.name # Error: undefined method 'name' for Nil
# Must handle nil case
if user
UserResponse.new(user)
else
raise Azu::Response::NotFound.new("/users/#{params["id"]}")
end
enddef call : Azu::Response
case action
when "create"
CreateResponse.new(create_item)
when "delete"
status 204
Azu::Response::Empty.new
else
raise Azu::Response::BadRequest.new("Unknown action")
end
endclass CacheHandler(T) < Azu::Handler::Base
def call(context)
key = cache_key(context)
if cached = Azu.cache.get(key)
return T.from_json(cached)
end
call_next(context)
end
endError: no overload matches 'User.find' with types (String)
Overloads are:
- User.find(id : Int64)
Did you mean to convert the argument?def call : UserResponse # Always specify return typealias UserId = Int64
alias Email = Stringcase status
when .pending?
# ...
when .active?
# ...
when .archived?
# ...
end
# Compiler warns if case not exhaustivedef find(id : Int64 | String)
# Accept either type
end