Tutorial
Build a complete, working Azu application from scratch. This comprehensive tutorial will walk you through creating a user management API with type-safe endpoints, validation, real-time features, and a web interface.
What You'll Build
By the end of this tutorial, you'll have a fully functional application with:
✅ Type-safe API endpoints for user management (CRUD operations)
✅ Request validation with detailed error messages
✅ Real-time notifications via WebSocket
✅ Template rendering with hot reloading
✅ Comprehensive error handling
✅ Web interface for user interaction
✅ RESTful API design following best practices
Project Setup
1. Create Project Structure
# Create project directory
mkdir user-manager
cd user-manager
# Initialize Crystal project
crystal init app user_manager
cd user_manager2. Add Dependencies
Edit shard.yml:
name: user_manager
version: 0.1.0
authors:
- Your Name <you@example.com>
dependencies:
azu:
github: azutoolkit/azu
version: ~> 0.4.14
crystal: >= 0.35.0
license: MITInstall dependencies:
shards install3. Create Project Structure
# Create directories for organized code
mkdir -p src/{models,requests,responses,endpoints,channels}
mkdir -p templates/users
mkdir -p public/cssBuilding the Application
Step 1: Define the User Model
Create src/models/user.cr:
# Simple in-memory user model for demonstration
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
endStep 2: Create Request Contracts
Create src/requests/create_user_request.cr:
struct CreateUserRequest
include Azu::Request
getter name : String
getter email : String
getter age : Int32?
def initialize(@name = "", @email = "", @age = nil)
end
# Validation rules
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"
endCreate src/requests/update_user_request.cr:
struct UpdateUserRequest
include Azu::Request
getter name : String?
getter email : String?
getter age : Int32?
def initialize(@name = nil, @email = nil, @age = nil)
end
# Validation rules
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"
endStep 3: Create Response Objects
Create src/responses/user_response.cr:
struct 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
endCreate src/responses/users_list_response.cr:
struct 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
endStep 4: Create Endpoints
Create src/endpoints/create_user_endpoint.cr:
struct 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
)
# Broadcast to WebSocket subscribers
UserNotificationChannel.broadcast_user_created(user)
# Set response status and return
status 201
UserResponse.new(user)
end
endCreate src/endpoints/list_users_endpoint.cr:
struct ListUsersEndpoint
include Azu::Endpoint(Azu::Request::Empty, UsersListResponse)
get "/users"
def call : UsersListResponse
users = User.all
UsersListResponse.new(users)
end
endCreate src/endpoints/show_user_endpoint.cr:
struct ShowUserEndpoint
include Azu::Endpoint(Azu::Request::Empty, 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
endCreate src/endpoints/update_user_endpoint.cr:
struct 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 = User.find_by_email(email)
unless existing_user.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
)
# Broadcast update
UserNotificationChannel.broadcast_user_updated(user)
UserResponse.new(user)
end
endCreate src/endpoints/delete_user_endpoint.cr:
struct DeleteUserEndpoint
include Azu::Endpoint(Azu::Request::Empty, 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
# Broadcast deletion
UserNotificationChannel.broadcast_user_deleted(user)
# Delete user
user.delete
status 204
Azu::Response::Empty.new
end
endStep 5: Create WebSocket Channel
Create src/channels/user_notification_channel.cr:
class UserNotificationChannel < Azu::Channel
CONNECTIONS = Set(HTTP::WebSocket).new
ws "/notifications"
def on_connect
CONNECTIONS << socket.not_nil!
send_to_client({
type: "connected",
message: "Connected to user notifications",
timestamp: Time.utc.to_rfc3339
})
Log.info { "User connected. Total connections: #{CONNECTIONS.size}" }
end
def on_message(message : String)
begin
data = JSON.parse(message)
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
rescue JSON::ParseException
send_to_client({type: "error", message: "Invalid JSON"})
end
end
def on_close(code, message)
CONNECTIONS.delete(socket)
Log.info { "User disconnected. Total connections: #{CONNECTIONS.size}" }
end
# Broadcast user created event
def self.broadcast_user_created(user : User)
message = {
type: "user_created",
user: {
id: user.id,
name: user.name,
email: user.email,
age: user.age
},
timestamp: Time.utc.to_rfc3339
}
broadcast_to_all(message)
end
# Broadcast user updated event
def self.broadcast_user_updated(user : User)
message = {
type: "user_updated",
user: {
id: user.id,
name: user.name,
email: user.email,
age: user.age
},
timestamp: Time.utc.to_rfc3339
}
broadcast_to_all(message)
end
# Broadcast user deleted event
def self.broadcast_user_deleted(user : User)
message = {
type: "user_deleted",
user_id: user.id,
timestamp: Time.utc.to_rfc3339
}
broadcast_to_all(message)
end
private def self.broadcast_to_all(message)
CONNECTIONS.each do |socket|
spawn socket.send(message.to_json)
end
end
private def send_to_client(data)
socket.not_nil!.send(data.to_json)
end
endStep 6: Create Templates
Create templates/users/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>User Manager</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
}
.user-card {
border: 1px solid #ddd;
padding: 15px;
margin: 10px 0;
border-radius: 5px;
}
.user-form {
margin: 20px 0;
padding: 20px;
background: #f9f9f9;
border-radius: 5px;
}
.form-group {
margin: 10px 0;
}
label {
display: block;
margin-bottom: 5px;
}
input,
button {
padding: 8px;
margin: 5px;
}
.error {
color: red;
}
.success {
color: green;
}
#notifications {
margin: 20px 0;
padding: 10px;
background: #e8f5e8;
border-radius: 5px;
}
</style>
</head>
<body>
<h1>User Manager</h1>
<div id="notifications">
<h3>Real-time Notifications</h3>
<div id="notification-messages"></div>
</div>
<div class="user-form">
<h2>Create New User</h2>
<form id="create-user-form">
<div class="form-group">
<label for="name">Name:</label>
<input type="text" id="name" name="name" required />
</div>
<div class="form-group">
<label for="email">Email:</label>
<input type="email" id="email" name="email" required />
</div>
<div class="form-group">
<label for="age">Age:</label>
<input type="number" id="age" name="age" min="1" max="150" />
</div>
<button type="submit">Create User</button>
</form>
</div>
<div id="users-list">
<h2>Users</h2>
<div id="users-container"></div>
</div>
<script>
// WebSocket connection
const ws = new WebSocket("ws://localhost:4000/notifications");
const notificationMessages = document.getElementById(
"notification-messages"
);
const usersContainer = document.getElementById("users-container");
ws.onopen = function () {
addNotification("Connected to server");
ws.send(JSON.stringify({ type: "subscribe" }));
};
ws.onmessage = function (event) {
const data = JSON.parse(event.data);
handleNotification(data);
};
ws.onclose = function () {
addNotification("Disconnected from server");
};
function addNotification(message) {
const div = document.createElement("div");
div.textContent = `${new Date().toLocaleTimeString()}: ${message}`;
notificationMessages.appendChild(div);
}
function handleNotification(data) {
switch (data.type) {
case "user_created":
addNotification(
`User created: ${data.user.name} (${data.user.email})`
);
loadUsers();
break;
case "user_updated":
addNotification(`User updated: ${data.user.name}`);
loadUsers();
break;
case "user_deleted":
addNotification(`User deleted: ID ${data.user_id}`);
loadUsers();
break;
case "connected":
case "subscribed":
addNotification(data.message);
break;
}
}
// Form submission
document
.getElementById("create-user-form")
.addEventListener("submit", async function (e) {
e.preventDefault();
const formData = new FormData(e.target);
const userData = {
name: formData.get("name"),
email: formData.get("email"),
age: formData.get("age") ? parseInt(formData.get("age")) : null,
};
try {
const response = await fetch("/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(userData),
});
if (response.ok) {
const user = await response.json();
addNotification(`User created successfully: ${user.name}`);
e.target.reset();
loadUsers();
} else {
const error = await response.json();
addNotification(`Error: ${error.Detail}`);
}
} catch (error) {
addNotification(`Error: ${error.message}`);
}
});
// Load users
async function loadUsers() {
try {
const response = await fetch("/users");
const data = await response.json();
usersContainer.innerHTML = "";
data.users.forEach((user) => {
const userCard = document.createElement("div");
userCard.className = "user-card";
userCard.innerHTML = `
<h3>${user.name}</h3>
<p><strong>Email:</strong> ${user.email}</p>
<p><strong>Age:</strong> ${
user.age || "Not specified"
}</p>
<p><strong>Created:</strong> ${new Date(
user.created_at
).toLocaleString()}</p>
<button onclick="deleteUser(${user.id})">Delete</button>
`;
usersContainer.appendChild(userCard);
});
} catch (error) {
addNotification(`Error loading users: ${error.message}`);
}
}
async function deleteUser(id) {
if (confirm("Are you sure you want to delete this user?")) {
try {
const response = await fetch(`/users/${id}`, {
method: "DELETE",
});
if (response.ok) {
addNotification("User deleted successfully");
loadUsers();
} else {
addNotification("Error deleting user");
}
} catch (error) {
addNotification(`Error: ${error.message}`);
}
}
}
// Load users on page load
loadUsers();
</script>
</body>
</html>Step 7: Create Main Application
Create src/user_manager.cr:
require "azu"
# Load all application files
require "./models/*"
require "./requests/*"
require "./responses/*"
require "./endpoints/*"
require "./channels/*"
module UserManager
include Azu
configure do |config|
# Server configuration
config.port = ENV.fetch("PORT", "4000").to_i
config.host = ENV.fetch("HOST", "0.0.0.0")
# Template configuration
config.templates.path = ["templates"]
config.template_hot_reload = config.env.development?
# Upload configuration
config.upload.max_file_size = 10.megabytes
config.upload.temp_dir = "/tmp/uploads"
# Logging
config.log.level = config.env.development? ? Log::Severity::DEBUG : Log::Severity::INFO
end
# Define routes
router do
root :web, HomeEndpoint
routes :web, "/api" do
get "/users", ListUsersEndpoint
get "/users/:id", ShowUserEndpoint
post "/users", CreateUserEndpoint
put "/users/:id", UpdateUserEndpoint
delete "/users/:id", DeleteUserEndpoint
end
end
end
# HTML endpoint for the main page
struct HomeEndpoint
include Azu::Endpoint(Azu::Request::Empty, Azu::Response::Text)
include Azu::Templates::Renderable
get "/"
def call : Azu::Response::Text
view "users/index.html", {
title: "User Manager",
users: User.all
}
end
end
# Start the application
UserManager.start [
Azu::Handler::RequestId.new,
Azu::Handler::Rescuer.new,
Azu::Handler::Logger.new,
Azu::Handler::CORS.new,
Azu::Handler::Static.new("public"),
HomeEndpoint.new,
]Running Your Application
1. Start the Server
# Run the application
crystal run src/user_manager.crYou should see:
Server started at Mon 12/04/2023 10:30:45.
⤑ Environment: development
⤑ Host: 0.0.0.0
⤑ Port: 4000
⤑ Startup Time: 12.34 millis2. Test the API
Create a User
curl -X POST http://localhost:4000/api/users \
-H "Content-Type: application/json" \
-d '{
"name": "Alice Smith",
"email": "alice@example.com",
"age": 30
}'Response:
{
"id": 1,
"name": "Alice Smith",
"email": "alice@example.com",
"age": 30,
"created_at": "2023-12-04T15:30:45Z",
"updated_at": "2023-12-04T15:30:45Z"
}List Users
curl http://localhost:4000/api/usersResponse:
{
"users": [
{
"id": 1,
"name": "Alice Smith",
"email": "alice@example.com",
"age": 30,
"created_at": "2023-12-04T15:30:45Z"
}
],
"count": 1,
"timestamp": "2023-12-04T15:30:45Z"
}Get a Specific User
curl http://localhost:4000/api/users/1Update a User
curl -X PUT http://localhost:4000/api/users/1 \
-H "Content-Type: application/json" \
-d '{
"name": "Alice Johnson",
"age": 31
}'Delete a User
curl -X DELETE http://localhost:4000/api/users/13. Test the Web Interface
Open your browser and navigate to http://localhost:4000. You'll see:
A form to create new users
A list of all users
Real-time notifications when users are created, updated, or deleted
4. Test WebSocket Notifications
Open the browser console and watch for real-time notifications when you:
Create a new user via the form
Delete a user via the API
Update a user via the API
Testing Error Handling
Test Validation Errors
# Missing required fields
curl -X POST http://localhost:4000/api/users \
-H "Content-Type: application/json" \
-d '{}'Response:
{
"Status": "Unprocessable Entity",
"Title": "Validation Error",
"Detail": "The request could not be processed due to validation errors.",
"FieldErrors": {
"name": ["Name must be between 2 and 50 characters"],
"email": ["Email must be a valid email address"]
},
"ErrorId": "err_abc123",
"Fingerprint": "validation_error_abc",
"Timestamp": "2023-12-04T15:35:12Z"
}Test Duplicate Email
# Try to create user with existing email
curl -X POST http://localhost:4000/api/users \
-H "Content-Type: application/json" \
-d '{
"name": "Bob Smith",
"email": "alice@example.com",
"age": 25
}'Test Not Found
# Try to get non-existent user
curl http://localhost:4000/api/users/999Project Structure
Your completed project should look like:
user_manager/
├── shard.yml
├── shard.lock
├── src/
│ ├── user_manager.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
│ └── channels/
│ └── user_notification_channel.cr
├── templates/
│ └── users/
│ └── index.html
├── public/
│ └── css/
└── spec/Key Concepts You've Learned
Endpoints
Type Safety: Every endpoint defines exactly what it accepts and returns
HTTP Methods: Support for GET, POST, PUT, DELETE and more
Route Parameters: Access to URL parameters like
:idRequest Context: Access to headers, query parameters, and more
Request Contracts
Validation: Automatic validation using the Schema library
Type Safety: Compile-time guarantees for request data
Error Messages: Clear, actionable validation errors
Flexibility: Support for optional and required fields
Response Objects
Structured Output: Consistent JSON responses
Status Codes: Proper HTTP status code handling
Error Handling: Comprehensive error responses
Serialization: Automatic JSON serialization
WebSocket Channels
Real-time Communication: Bidirectional communication with clients
Event Broadcasting: Notify all connected clients of changes
Connection Management: Handle connections and disconnections
Message Handling: Process incoming messages from clients
Templates
Hot Reload: Automatic template reloading in development
Variable Interpolation: Pass data to templates
Static Assets: Serve CSS, JavaScript, and other assets
HTML Generation: Server-side HTML rendering
Next Steps
Congratulations! You've built a complete Azu application. Here's what to explore next:
Architecture Overview - Understand how Azu works under the hood
Endpoints Deep Dive - Master endpoint patterns and best practices
Request Contracts - Advanced validation techniques
Response Objects - Structured response handling
Real-time Features - Advanced WebSocket patterns
Templates - Template engine and markup DSL
Testing - Write comprehensive tests for your application
Extending Your Application
Consider adding these features:
Database Integration: Replace in-memory storage with PostgreSQL or MySQL
Authentication: Add user authentication and authorization
File Uploads: Handle user avatar uploads
Pagination: Add pagination to the users list
Search and Filtering: Implement search and filter capabilities
Email Notifications: Send emails when users are created
API Rate Limiting: Implement rate limiting for API endpoints
Request Logging: Add comprehensive request logging and monitoring
Caching: Implement caching for better performance
Background Jobs: Add background job processing
Production Considerations
When deploying to production:
Environment Variables: Use environment variables for configuration
Database: Set up a proper database (PostgreSQL recommended)
Logging: Configure proper logging levels and outputs
Monitoring: Set up application monitoring and alerting
Security: Implement proper security measures
Performance: Optimize for production workloads
Scaling: Plan for horizontal scaling if needed
Your first complete Azu application is ready! You now have a solid foundation for building more complex applications with type safety, real-time features, and excellent developer experience. 🚀
Last updated
Was this helpful?
