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:

  1. Configuration → - Learn about advanced configuration options

  2. Core Concepts → - Deep dive into endpoints, requests, and responses

  3. Real-Time Features → - Master WebSocket channels and live components

  4. Templates → - Learn about template rendering and markup DSL

  5. 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?