Your First Application
Build a complete, working Azu application from scratch. This guide will walk you through creating a user management API with type-safe endpoints, validation, and real-time features.
What You'll Build
By the end of this guide, you'll have a fully functional application with:
✅ Type-safe API endpoints for user management
✅ Request validation with detailed error messages
✅ Real-time notifications via WebSocket
✅ Template rendering with hot reloading
✅ Comprehensive error handling
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_manager
2. 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.5.2
crystal: >= 0.35.0
license: MIT
Install dependencies:
shards install
3. Create Project Structure
# Create directories for organized code
mkdir -p src/{models,requests,responses,endpoints,channels}
mkdir -p templates/users
mkdir -p public/css
Building 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
end
Step 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"
end
Create 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"
end
Step 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
end
Create 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
end
Step 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
end
Create 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
end
Create 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
end
Create 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
end
Create 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
end
Step 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
end
Step 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
# Server configuration
port = ENV.fetch("PORT", "4000").to_i
host = ENV.fetch("HOST", "0.0.0.0")
# Template configuration
templates.path = ["templates"]
template_hot_reload = env.development?
# Upload configuration
upload.max_file_size = 10.megabytes
upload.temp_dir = "/tmp/uploads"
# Logging
log.level = env.development? ? Log::Severity::DEBUG : Log::Severity::INFO
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
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.cr
You 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 millis
2. Test the API
Create a User
curl -X POST http://localhost:4000/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/users
Response:
{
"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/users/1
Update a User
curl -X PUT http://localhost:4000/users/1 \
-H "Content-Type: application/json" \
-d '{
"name": "Alice Johnson",
"age": 31
}'
Delete a User
curl -X DELETE http://localhost:4000/users/1
3. 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/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/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/users/999
Project 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/
Next Steps
Congratulations! You've built a complete Azu application. Here's what to explore next:
Configuration → - Learn about advanced configuration options
Core Concepts → - Deep dive into endpoints, requests, and responses
Real-Time Features → - Master WebSocket channels and live components
Templates → - Learn about template rendering and markup DSL
Testing → - Write comprehensive tests for your application
Extending Your Application
Consider adding these features:
Database integration with PostgreSQL or MySQL
Authentication and authorization
File uploads for user avatars
Pagination for the users list
Search and filtering capabilities
Email notifications when users are created
API rate limiting
Request logging and monitoring
Your first Azu application is complete! 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?