Skip to content

Latest commit

 

History

History
1355 lines (1065 loc) · 48.3 KB

TUTORIAL.md

File metadata and controls

1355 lines (1065 loc) · 48.3 KB

Mini App Development Step-by-Step Guide With React (Vite) & Python (Aiogram + FastAPI)

Telegram Bot Setup

First before we start creating an application, we need to setup a Telegram bot. This bot will be used to communicate with our application.

  1. Create a new bot using BotFather
  2. Type /newbot and follow the instructions
  3. Copy the bot token and save it somewhere. We will need it later
    ...
    
    Use this token to access the HTTP API:
    <your_bot_token>
    
    ...
    

Ngrok Setup

Telegram mini app requires a public url (https) to work. We will use ngrok to expose our local server to the internet and obtain https temporary urls.

  1. Download & install ngrok from here
  2. Edit ngrok configuration file
    sudo ngrok config edit      
    
  3. Copy and paste this configuration. Remember to specify your auth token. You can get your auth token from dashboard here
    version: "2"
    authtoken: <your_auth_token> # change this to your auth token
    tunnels:
      front:
        addr: 3000
        proto: http
      back:
        addr: 8000
        proto: http   
    
  4. Save and exit the file: Ctrl + X, then Y, then Enter
  5. Check configuration
    ngrok config check
    
  6. Start ngrok
    ngrok start --all
    
  7. Copy and save somewhere the forwarding url for front and back. We will need them later.

Client side

Explanation & tutorial for client side. All of this happens inside frontend folder.

React Setup with Vite

This project utilizes React & Vite as the frontend framework. To start developing, follow these steps:

  1. Make sure you have Node.js installed. You can download it from here
  2. Create react app with vite - Vite Setup Guide
    npm create vite@latest
    
    Then follow the prompts. (Make sure you choose react as the framework and javascript as the language) I didn't use Typescript because it's a bit more complicated to setup.
  3. Navigate to project and install dependencies
    cd <project_name>
    npm install
    
  4. Run the project to test if it works
    npm run dev
    

Tailwind & NextUI Setup

In my project, I also use tailwind and NextUI as the UI framework. You can setup them by following these steps:

  1. Install tailwindcss. Follow this straightforward tutorial to setup tailwindcss - Tailwind Setup Guide

  2. Then, install NextUI. Follow this straightforward tutorial to setup NextUI - NextUI Setup Guide (By the way, you already completed step 1 which is installing tailwindcss)

Telegram Mini App Integration

Now lets initialize telegram mini app in our project, setup styling & create custom hook to work with window.Telegram.WebApp object.

Adding Telegram Script

Initialize Telegram mini app Documentation here

To connect your Mini App to the Telegram client, place the script telegram-web-app.js in the <head> tag before any other scripts in frontend/index.html, using this code:

<script src="https://telegram.org/js/telegram-web-app.js"></script>
Configuring App Styles according to telegram theme

Mini Apps can adjust the appearance of the interface to match the Telegram user's app in real time.

Telegram provides a great API to access theme params. Documentation here

Let's set global html document theme so NextUI and Tailwind can adjust automatically according to the theme. Navigate to frontend/index.html and add following script in <head> tag

<script>
     function setThemeClass() {
         document.documentElement.className = Telegram.WebApp.colorScheme;
     }
     Telegram.WebApp.onEvent('themeChanged', setThemeClass);
     setThemeClass();
</script>

This script ensures that if Telegram theme changes, our app will adjust accordingly. Moreover, NextUi and TailwindCSS will also adjust accordingly.

Lets also set styles with help of css variables that Telegram provides to us.

Navigate to src/index.css and add following styles

body {
   color: var(--tg-theme-text-color);
   background: var(--tg-theme-bg-color);
}

.hint { 
   color: var(--tg-theme-hint-color);
}

.link {
   color: var(--tg-theme-link-color);
}

.tg-button {
   background: var(--tg-theme-button-color);
   color: var(--tg-theme-button-text-color);
}

.card {
  background: var(--tg-theme-bg-color);
}

You can change class names according to your needs. I used these classes in my project.

Creating custom React hook to work with Telegram object.

Now lets create a custom hook to work with window.Telegram.WebApp object.

Create a new file in src/hooks/ with name useTelegram.js and add following code

const tg = window.Telegram.WebApp;  // access telegram object

export function useTelegram() {
  // Telegram docs for main button methods - https://core.telegram.org/bots/webapps#mainbutton 
  const onToggleButton = () => {   // toggle telegram main button.
    if (tg.MainButton.isVisible) {
      tg.MainButton.hide();
    } else {
      tg.MainButton.show();
    }
  }

  return {
    onToggleButton,  // return toggle button function
    tg, // return telegram window object
    user: tg.initDataUnsafe?.user,  // return user data
    queryId: tg.initDataUnsafe?.queryId,  // return query id
  }
}

Note:

I provide minimal functionality in this hook. You can add more functionality according to your needs.

React Router V6 Setup

Next lets setup routing. We will use react-router-dom version 6 for this.

Here is a detailed tutorial of how to use react-router-dom v6 - React Router v6 Tutorial. I will focus on the specific parts only that I used in my project.

  1. Navigate to src/main.jsx and configure the router
import React from "react";
import ReactDOM from "react-dom/client";

// Import react router
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { NextUIProvider } from "@nextui-org/react";

import "./index.css";

// Route components
import BaseLayout from "@/pages/BaseLayout.jsx";
import Index from "@/pages/Index.jsx";
import BookingIndex from "@/pages/BookingIndex.jsx";

// Initialize react router
const router = createBrowserRouter([
 {
   path: "/",  // base route path
   element: <BaseLayout />,   // Define main layout component. All child routes will be rendered inside this component
   children: [
     { path: "/", element: <Index /> },  // Define index component
     { path: "/book/:venueId", element: <BookingIndex /> },  // Define booking index component. Notice that it has venueId parameter in the url
   ],
 },
]);

ReactDOM.createRoot(document.getElementById("root")).render(
 <React.StrictMode>
   <NextUIProvider>
      // add router provider
     <RouterProvider router={router} />
   </NextUIProvider>
 </React.StrictMode>
);

Then lets create the layout component. This component will be used as the main layout for all pages.

  1. Navigate to /src/pages/BaseLayout.jsx and create the layout component
import { useEffect } from "react";

import { Outlet } from "react-router-dom"

import { useTelegram } from "@/hooks/useTelegram";

const BaseLayout = () => {
 const { tg } = useTelegram();  // obtain telegram object from custom hook that we created earlier

 // Call the ready method as soon as the interface is loaded
 useEffect(() => {
   tg.ready();
 });

 return (
   <div
     className="w-full min-h-screen p-4"
   >
     <Outlet />  // Place where child route components will be rendered
   </div>
 );
}

export default BaseLayout;
  • <Outlet> is a specific layout component from react-router-dom v6. It will render all child routes inside this component.
  • Notice that I also added the tg.ready() (method that informs the Telegram app that the Mini App is ready to be displayed). By wrapping it inside useEffect hook, we ensure that this method will be called as soon as the interface is loaded.

Now, lets create the index page component. This component will be rendered when user opens the mini app.

  1. Navigate to /src/pages/Index.jsx and create the index page component
import { useState, useEffect } from 'react'

import axiosInstance from '@/services/api'

import VenueCard from "@/components/VenueCard";
import { useTelegram } from '@/hooks/useTelegram';


const Index = () => {
  const { tg } = useTelegram();
  const [venueData, setVenueData] = useState([])

  useEffect(() => {
    const getVenueData = async () => {
      await axiosInstance.get(
        "/venues/"
      ).then((res) => {
        setVenueData(res.data)
      })
    }
    getVenueData();

    // Hide buttons so they are not visible when we navigate from page that had them visible
    // Hide back button
    tg.BackButton.hide();
    // Hide main button
    tg.MainButton.hide();
  }, [])

  return (
    <>
      <header className="flex justify-center items-center pb-2 font-bold">
        Venue Listing
      </header>
      <section className="grid grid-cols-2 grid-rows-2 gap-5">
        {venueData.map((venue) => (
          <VenueCard key={venue.id} venue={venue} />
        ))}
      </section>
    </>
  );
}

export default Index;
  • Here useEffect hook is used to fetch venue data from backend. It runs on page load and fetches data from /venues/ endpoint. (more about this endpoint later when we will implement backend side)
  • Fetched data is stored in venueData state variable. We use this variable to render venue cards via cycling through it with map function.
  • I also added tg.BackButton.hide() and tg.MainButton.hide() to hide back and main buttons. This is done so that these buttons are not visible when we navigate from page that had them visible.

You should also create a separate axios Instance src/services/api.js that automatically sets base url for all requests.

  • I assume is axios installed at this point. If not, install it with npm install axios
import axios from "axios";


const axiosInstance = axios.create({
  baseURL: `${import.meta.env.VITE_BASE_API_URL}`
});

export default axiosInstance;

Since, we use environment variable to store base url, we need to create .env file in frontend directory and add following line to it

VITE_BASE_API_URL=<back_url>

Replace <back_url> with the url that you obtained from ngrok earlier.

Next, create a venue card component to display venue data. You can create your own component or use mine. Here is the code for venue card component:

import { useNavigate } from "react-router-dom";

import { Card, CardHeader, CardFooter, Image, Button } from "@nextui-org/react";

const VenueCard = ({ venue }) => {
  const navigate = useNavigate();

  return (
    <Card radius="lg" className="border-none max-w-xl h-full">
      <CardHeader className="absolute z-10 top-0 flex-col !items-start">
        <span className="font-bold text-lg drop-shadow-2xl">{venue.name}</span>
      </CardHeader>
      <Image
        src={`/images/${venue.id}.jpg`}
        removeWrapper
        className="z-0 object-cover"
        loading="lazy"
      />
      <CardFooter className="flex flex-col items-start overflow-hidden pb-1 absolute bottom-0 shadow-small z-10">
        <Button
          className="text-xs font-bold tg-button w-full"
          variant="flat"
          color="default"
          radius="md"
          size="sm"
          onClick={() => {navigate(`/book/${venue.id}`)}}
        >
          Book Now
        </Button>
      </CardFooter>
    </Card>
  );
};

export default VenueCard;
  • For demonstration purposes I assign image url to /images/${venue.id}.jpg. Ideally image links should be provided by backend.
  • Also, I use useNavigate hook from react-router-dom v6 to navigate to booking page. I also passed venueId parameter in the url. We get this parameter to from backend.

At this point, we have successfully setup index page. Now lets setup booking page.

  1. Navigate to /src/pages/BookingIndex.jsx and create the booking page component
import { useState, useEffect, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";

import { Image, Spinner, Input } from "@nextui-org/react";

import axiosInstance from "@/services/api";
import { useTelegram } from "@/hooks/useTelegram";

const STATUS = {
  IDLE: "IDLE",
  LOADING: "LOADING",
  ERROR: "ERROR",
  SUCCESS: "SUCCESS",
};

const BookingIndex = () => {
  const { tg } = useTelegram();
  const navigate = useNavigate();

  const { venueId } = useParams();
  const [venue, setVenue] = useState(null);
  const [status, setStatus] = useState(STATUS.IDLE);

  const {
    register,
    formState: { errors },
    handleSubmit,
  } = useForm();
  const onSubmit = useCallback(
    (data) => {
      const abortController = new AbortController();

      // Send required fields
      axiosInstance.post(`/bookings/${venueId}`, {
        signal: abortController.signal,
        _auth: tg.initData,
        queryId: tg.initData.queryId,
        under_name: data.under_name,
        date: data.date,
        comment: data.comment,
      });
    },
    [venueId, tg]
  );

  useEffect(() => {
    const abortController = new AbortController();
    tg.onEvent("mainButtonClicked", () => {
      handleSubmit(onSubmit)();
    });

    return () => {
      abortController.abort();
    };
  }, [tg, handleSubmit, onSubmit]);

  useEffect(() => {
    setStatus(STATUS.LOADING);

    const getVenueData = async () => {
      try {
        const response = await axiosInstance.get(`/venues/${venueId}`);
        setVenue(response.data);
        setStatus(STATUS.SUCCESS);
      } catch (error) {
        setStatus(STATUS.ERROR);
      }
    };

    getVenueData();

    // set telegram button
    tg.MainButton.text = "Book Now";
    tg.MainButton.show();
    // show back button
    tg.BackButton.show();
  }, [venueId]);

  // handle back button click
  tg.onEvent("backButtonClicked", () => {
    navigate("/");
  });

  return (
    <section>
      {status === STATUS.SUCCESS ? (
        <>
          <div className="flex flex-row gap-3 mb-4">
            <Image
              src={`/images/${venueId}.jpg`}
              className="w-20 h-20"
              loading="lazy"
            />
            <div className="flex flex-col justify-center">
              <span className="text-xl font-bold">{venue.name}</span>
              <span className="text-xs hint mb-1">
                {venue.address}, {venue.city}
              </span>
              <span className="text-xs">{venue.description}</span>
            </div>
          </div>
          <form className="flex flex-col gap-2">
            <Input
              variant="bordered"
              label="Under Name"
              labelPlacement="outside"
              placeholder="your name"
              validationState={errors.under_name ? "invalid" : "valid"}
              errorMessage={errors.under_name && errors.under_name.message}
              {...register("under_name", {
                required: "under name is required",
              })}
            />
            <p className="text-small">Date</p>
            <Input
              type="date"
              variant="bordered"
              validationState={errors.date ? "invalid" : "valid"}
              errorMessage={errors.date && errors.date.message}
              {...register("date", { required: "date is required" })}
            />
            <Input
              variant="bordered"
              label="Comment (optional)"
              labelPlacement="outside"
              placeholder="any comments?"
              {...register("comment")}
            />
          </form>
        </>
      ) : status === STATUS.LOADING ? (
        <div className="flex justify-center items-center h-48">
          <Spinner color="primary" size="lg" />
        </div>
      ) : (
        <div className="flex justify-center items-center h-48">
          <span className="text-2xl font-bold">Error</span>
        </div>
      )}
    </section>
  );
};

export default BookingIndex;

Wow! That's a lot of code. Let's break it down.

  1. First, we define STATUS object. This object will be used to display different components depending on the status of the request. We will use this object later in the code.
  2. Then, we define useForm hook from react-hook-form library. This hook will be used to handle form data. Documentation here
  3. Next, we define onSubmit function. This function will be used to send form data to backend. We use axiosInstance to send data. We created this instance earlier.
    • Here you can see that we send a lot of stuff to backend. Lets look at it in more detail:
      • signal: abortController.signal
      • _auth: tg.initData - this is auth data that we obtained from WebAppInitData object. It is essential to check the data validity in our backend. Validating data received via the Mini App
      • queryId: tg.initData.queryId - this is query id that we obtained from WebAppInitData object. It is a unique identifier for the Mini App session, required for sending messages via the answerWebAppQuery method.
      • under_name: data.under_name - this is name from our form input
      • date: data.date - this is date from our form input
      • comment: data.comment - this is optional field from our form input
  4. Then, we define useEffect hook. This hook will be used to handle mainButtonClicked event. This event is triggered when user clicks on the main button. We use handleSubmit function from react-hook-form to handle form submission. We also pass onSubmit function to handleSubmit function. This is done so that onSubmit function is called when user clicks on the main button.
  5. Next, we define another useEffect hook. This hook will be used to fetch venue data from backend. We use axiosInstance to fetch data. We also set tg.MainButton.text to Book Now and show the main & back buttons. Telegram Main Button Docs, Telegram Back Button Docs
  6. Then, we define tg.onEvent("backButtonClicked") event. This event is triggered when user clicks on the back button. We use useNavigate hook from react-router-dom to navigate to index page.
  7. Finally, we render different components depending on the status of the request. If status is SUCCESS, we render the form. If status is LOADING, we render the spinner. If status is ERROR, we render the error message.

Inside return statement we return html with out layout and components. - Here I use some ready components from NextUI Library. - Input is one of these components and it is simply styled as documentation says.

Backend side

Explanation & tutorial for backend side. All of this happens inside server folder.

Environment Setup

Okay, now lets setup backend side. We will use FastAPI as the core backend framework. To start developing, follow these steps:

  1. Make sure you have Python 3.8+ installed. You can download it from here
  2. Create a new virtual environment
    python3 -m venv venv
    
  3. Activate virtual environment
    source venv/bin/activate
    
  4. Install dependencies
    pip install -r requirements.txt
    

For this project I use aiogram library to work with telegram bot. Documentation here

To combine FastAPI and aiogram I use aiogram-fastapi-server library. Also this library has its own bot template that shows how to use the library.

I will not discuss project structure here. You can read about it in README.md

Base FastAPI and Aiogram app Setup

First lets create app factory function. This function will be used to create FastAPI app.

  1. Navigate to server/src/app.py and create app factory function
import sys
import logging

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi_async_sqlalchemy import SQLAlchemyMiddleware

from src.config import settings


def create_app():
    """Application factory."""
    # logging configuration
    logging.basicConfig(level=logging.INFO, stream=sys.stdout)

    # configure application
    app = FastAPI(
        title="Venue Booking API",
        docs_url="/",
    )

    # Register application routers
    register_app_routers(app)

    # Add app middleware
    app.add_middleware(
        SQLAlchemyMiddleware,
        db_url=settings.SQLALCHEMY_DATABASE_URI,
        engine_args={  # SQLAlchemy engine example setup
            "echo": True,
            "pool_pre_ping": True
        },
    )
    app.add_middleware(
        CORSMiddleware,
        allow_origins=[settings.FRONT_BASE_URL],
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )

    return app


def register_app_routers(app: FastAPI):
    pass
  • Here we do several things
    • configure basic python logging logging.basicConfig(level=logging.INFO, stream=sys.stdout)
    • configure FastAPI app app = FastAPI(...)
    • register application routers register_app_routers(app). We will add routers later
    • add SQLAlchemy middleware app.add_middleware(SQLAlchemyMiddleware, ...). This middleware will be used to work with database. Documentation here
    • add CORS middleware app.add_middleware(CORSMiddleware, ...). Note that we specify our frontend url from ngrok in allow_origins allow_origins=[settings.FRONT_BASE_URL]. Documentation here

As you can see we import settings from src.config module. Lets create this module.

  1. Next, lets create main file with uvicorn server. Crete file main.py in server/ directory and add following code:
import uvicorn

from src.app import create_app

app = create_app()

if __name__ == "__main__":
    uvicorn.run(app, host="localhost", port=8000)
  • Here we import create_app function from src.app module and create FastAPI app with it.
  • Then we run uvicorn server with this app.
    • We use localhost as host and 8000 as port. You can change this according to your needs.
  • Uvicorn Documentation
  1. Navigate to server/src/config.py and create settings module
import os
from pathlib import Path

from pydantic_settings import BaseSettings, SettingsConfigDict

BASE_DIR = Path(__file__).resolve().parent.parent


class DevSettings(BaseSettings):
    DEBUG: bool = True
    SECRET_KEY: str = "NOT_A_SECRET"

    BOT_TOKEN: str
    FRONT_BASE_URL: str
    BACK_BASE_URL: str

    SQLALCHEMY_DATABASE_URI: str = 'sqlite+aiosqlite:///' + os.path.join(BASE_DIR, 'db.sqlite')
    SQLALCHEMY_TRACK_MODIFICATIONS: bool = False

    # Environment
    model_config = SettingsConfigDict(
        env_file=os.path.join(BASE_DIR, ".env"),
        env_file_encoding='utf-8'
    )


settings = DevSettings()
  • Here we use pydantic_settings library to load settings from .env file. Install it with pip install pydantic-settings if you haven't already. Documentation here
  • Lets break down some things that we define here:
    • BOT_TOKEN - this is the token that we obtained from BotFather earlier
    • FRONT_BASE_URL - this is the url that we obtained from ngrok earlier. It is used to handle CORS issues
    • BACK_BASE_URL - this is the url that we obtained from ngrok earlier. It is used to handle CORS issues
    • SQLALCHEMY_DATABASE_URI - this is the database url. We use aiosqlite library to work with database.
    • All of this variables are loaded from .env file. You can read more about .env file here
      • model_config - this is the configuration for pydantic_settings library. We specify .env file path and encoding here.

Lets also create .env file in src/ folder to store environment variables that we defined above. Add the following lines to .env file

SECRET_KEY=<secret_key>          # change this to random long string in production
BOT_TOKEN=<your_bot_token>       # change this to your bot token that you obtained from botfather
FRONT_BASE_URL=https://*********.ngrok-free.app   # change this to your front url from ngrok
BACK_BASE_URL=https://*********.ngrok-free.app    # change this to your back url from ngrok
  1. Now lets integrate bot with FastAPI app. Navigate to server/src/bot/__init__.py and add following code
from aiogram import Bot

from src.bot import start
from src.config import settings


# Bot initialization
bot = Bot(token=settings.BOT_TOKEN, parse_mode="HTML")

bot_routers = [start.router]
  • Here we initialize bot with Bot class from aiogram library. Documentation here
  • We also add bot routers that will handle bot commands.
  1. Now lets create bot routers. Navigate to server/src/bot/start.py and add following code
from aiogram import Bot, Router
from aiogram.filters import Command
from aiogram.types import (
    InlineKeyboardButton,
    InlineKeyboardMarkup,
    MenuButtonWebApp,
    Message,
    WebAppInfo,
)

router = Router()


welcome_message = """
Hey! What's up?\n
Looking where to hang out?\n
I got you covered! Press the button below to find the best places.
"""


@router.message(Command("start"))
async def command_start(message: Message, bot: Bot, base_url: str):
    await bot.set_chat_menu_button(
        chat_id=message.chat.id,
        menu_button=MenuButtonWebApp(text="See Venues", web_app=WebAppInfo(url=f"{base_url}")),
    )
    await message.answer(
        welcome_message,
        reply_markup=InlineKeyboardMarkup(
            inline_keyboard=[
                [
                    InlineKeyboardButton(
                        text="See Venue Listing", web_app=WebAppInfo(url=f"{base_url}")
                    )
                ]
            ]
        ),
    )
  • Here we first define bot router from aiogram library router = Router()
  • Then we add a handler for /start command @router.message(Command("start")). This will listen to every message and execute the function if the message is /start command.
    • Inside the handler we do two things:
      • We add a button to the chat menu await bot.set_chat_menu_button(...). This button will be visible in the chat menu. Documentation here
      • We send a welcome message with an inline button await message.answer(...). This inline button will be visible under the reply message. Documentation here

Note that both MenuButtonWebApp and InlineKeyboardButton have web_app parameter. web_app param | aiogram

  1. Now lets add bot to FastAPI app factory. Navigate to server/src/app.py and add following code
import sys
import logging

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

# add these 3 lines of imports
from aiogram_fastapi_server import SimpleRequestHandler, setup_application  # NEW
from aiogram import Bot, Dispatcher   # NEW
from aiogram.types import MenuButtonWebApp, WebAppInfo  # NEW

from fastapi_async_sqlalchemy import SQLAlchemyMiddleware

from src.config import settings

# import our bot and bot routers
from src.bot import bot, bot_routers # NEW


# Create a startup event handler
# NEW
async def on_startup(bot: Bot, base_url: str):   
    await bot.set_webhook(f"{settings.BACK_BASE_URL}/webhook")
    await bot.set_chat_menu_button(
        menu_button=MenuButtonWebApp(text="Book Venue", web_app=WebAppInfo(url=base_url))
    )


def create_app():
    """Application factory."""
    # logging configuration
    logging.basicConfig(level=logging.INFO, stream=sys.stdout)

    # Configure dispatcher
    dispatcher = Dispatcher()    # NEW
    dispatcher["base_url"] = settings.FRONT_BASE_URL  # NEW
    dispatcher.startup.register(on_startup)   # NEW

    # Register bot routers
    register_bot_router(dispatcher)  # NEW

    # configure application
    app = FastAPI(
        title="Venue Booking API",
        docs_url="/",
    )

    # Register application routers
    register_app_routers(app)

    # Add app middleware
    app.add_middleware(
        SQLAlchemyMiddleware,
        db_url=settings.SQLALCHEMY_DATABASE_URI,
        engine_args={  # SQLAlchemy engine example setup
            "echo": True,
            "pool_pre_ping": True
        },
    )
    app.add_middleware(
        CORSMiddleware,
        allow_origins=[settings.FRONT_BASE_URL],
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )

    # Add bot and dispatcher to application
    # NEW
    SimpleRequestHandler(
        dispatcher=dispatcher,
        bot=bot
    ).register(app, path="/webhook")
    setup_application(app, dispatcher, bot=bot)

    return app


# add this lines where we loop through bot routers and register them.
# NEW
def register_bot_router(dispatcher: Dispatcher):
    for router in bot_routers:
        dispatcher.include_router(router)


def register_app_routers(app: FastAPI):
    pass
  • Here we do several things:
    • First, we configure a bot startup event that sets menu button and webhook.
    • Then, we configure dispatcher dispatcher = Dispatcher(). Documentation here
      • We also add base_url variable to dispatcher. This variable will be used to pass base url to bot routers.
      • We also register on_startup event that we created earlier to dispatcher.
    • Then, we register bot routers register_bot_router(dispatcher). We loop through all bot routers and register them.

Okay, now we have a base setup that allows us to handle bot events and API calls. Let's move on to building the actual app.

First, lets create all necessary models and setup database migrations with alembic

Creating DB Models

  1. install sqlalchemy 2 if you haven't already. (Make sure you install version 2 because its incompatible with version 1)
    pip install sqlalchemy  
    
  2. Create a base model file server/src/models/base.py and add following code:
from datetime import datetime

from sqlalchemy import Column, Integer, DateTime
from sqlalchemy.ext.declarative import as_declarative


@as_declarative()
class PkBase:
    """Base model with default columns."""
    id = Column(Integer, primary_key=True, index=True)
    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
    created_at = Column(DateTime, default=datetime.utcnow)
  • This is a standard sqlalchemy model. @as_declarative decorator is used to create a base class for all models. Documentation here
  • PkBase is the base model that others inherit from. it has 3 properties. id is the primary key. updated_at and created_at are timestamps that are automatically updated when the model is updated or created.
  1. Create a venue model file server/src/models/venue.py and add following code:
from sqlalchemy import Column, String

from src.models.base import PkBase


class Venue(PkBase):
    """Venues model."""
    __tablename__ = 'venues'

    name = Column(String(255), nullable=False)
    description = Column(String(600), nullable=False)
    address = Column(String(255), nullable=False)
    city = Column(String(255), nullable=False)
  • Here we create a Venue model that inherits from PkBase model.
  • We also define __tablename__ property. This property is used to specify the table name in the database.
  1. Finally, create a booking model file server/src/models/booking.py and add following code:
from sqlalchemy import Column, String, Date, ForeignKey
from sqlalchemy.orm import relationship

from src.models.base import PkBase


class Booking(PkBase):
    """Booking model."""
    __tablename__ = 'bookings'

    venue_id = Column(String, ForeignKey('venues.id'))
    venue = relationship('Venue', lazy="selectin")
    user_id = Column(String)
    under_name = Column(String)
    date = Column(Date)
    comment = Column(String, nullable=True)
  • Here we create a Booking model that again inherits from PkBase model.
  • We also specify venue_id as a foreign key and venue as a relationship. More about SQLAlchemy relationships
  1. Also remember to add these models to server/src/models/__init__.py file. We need this to ensure that migrations will work properly and see our models.
from src.models.venue import Venue
from src.models.booking import Booking

Alembic Setup

  1. Install alembic if you haven't already
    pip install alembic
    
  2. Initialize async alembic migrations in server folder
    alembic init -t async migrations
    
  3. The folder named migrations will be created. Navigate to server/migrations/env.py and add modify the file as follows:
import asyncio
from logging.config import fileConfig

from sqlalchemy import pool, engine_from_config  # UPDATED
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import AsyncEngine  # NEW

from alembic import context

from src.config import settings  # NEW
from src.models.base import PkBase  # NEW

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config


if config.config_file_name is not None:
    fileConfig(config.config_file_name)

# Set metadata
target_metadata = PkBase.metadata   # UPDATED

target_metadata.naming_convention = {  # NEW
   "ix": "ix_%(column_0_label)s",
   "uq": "uq_%(table_name)s_%(column_0_name)s",
   "ck": "ck_%(table_name)s_%(constraint_name)s",
   "fk": "fk_%(table_name)s_%(column_0_name)"
         "s_%(referred_table_name)s",
   "pk": "pk_%(table_name)s"
}


def run_migrations_offline() -> None:
    url = settings.SQLALCHEMY_DATABASE_URI  # UPDATED
    context.configure(
        url=url,  # UPDATED
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={"paramstyle": "named"},
    )

    with context.begin_transaction():
        context.run_migrations()


# Other code ...


async def run_async_migrations() -> None:
    configuration = config.get_section(config.config_ini_section)  # UPDATED
    configuration["sqlalchemy.url"] = settings.SQLALCHEMY_DATABASE_URI  # UPDATED

    connectable = AsyncEngine(  # NEW
        engine_from_config(  # UPDATED
            configuration,  # UPDATED
            prefix="sqlalchemy.",
            poolclass=pool.NullPool,
            future=True,
        )
    )

    async with connectable.connect() as connection:
        await connection.run_sync(do_run_migrations)

    await connectable.dispose()

# Other code ...
  • Here we imported necessary libraries and functions.
  • Imported settings and PkBase from src.config and src.models.base respectively
  • Changed target_metadata to PkBase.metadata
  • Added naming convention to target_metadata.naming_convention. More about naming convention
  • Also Edited database url in run_migrations_offline function
  • Finally, Edited database configuration in run_async_migrations function. We provided AsyncEngine with custom url in configuration.
  1. Running migrations
  • You can run them manually with commands:
alembic revision --autogenerate -m "init"
alembic upgrade head
  • Or you can use one of my bash scripts to run them automatically
sudo bash scripts/linux/migrate.sh
  1. Now check your database with visual tool like DB Browser for SQLite. You should see tables created.

Creating API

Install pydantic if you haven't already

pip install pydantic

First, lets create pydantic data validation schemas. Navigate to server/src/schemas/ and add create following files:

venue.py

from pydantic import BaseModel


class VenueItem(BaseModel):
    id: int
    name: str
    description: str
    address: str
    city: str
  • Here we define VenueItem schema. This schema will be used to validate response data.
    • Its a basic pydantic schema model created according Venue database model fields. Documentation here

booking.py

from datetime import date

from pydantic import BaseModel, ConfigDict

from src.schemas.venue import VenueItem


class BookingItem(BaseModel):
    id: int
    user_id: int
    under_name: str
    date: date
    comment: str
    venue: VenueItem

    model_config = ConfigDict(from_attributes=True)


class BookingCreate(BaseModel):
    venue_id: int
    user_id: int
    under_name: str
    date: date
    comment: str
  • Here we define 2 separate models:
    • BookingItem is a response model.
      • Note that venue will be nested and represented with help of venue model that we created before.
    • BookingCreate is a create booking model. It will help us validated sent data from frontend and write it to database.
      • Note that venue will be represented with venue id.

Next let's create our API endpoints.

First lets expose venue model. Navigate to server/src/api/endpoints and create venue.py file. Add following code:

from typing import List
from fastapi import APIRouter
from sqlalchemy import select
from fastapi_async_sqlalchemy import db

from src.models.venue import Venue
from src.schemas.venue import VenueItem


router = APIRouter()


@router.get("/")
async def get_venues() -> List[VenueItem]:
    query = select(Venue)
    result = await db.session.execute(query)
    venues = result.scalars().all()
    return [
        VenueItem(
            id=row.id,
            name=row.name,
            description=row.description,
            address=row.address,
            city=row.city,
        ) for row in venues
    ]


@router.get("/{venue_id}")
async def get_venue_by_id(venue_id: int) -> VenueItem:
    query = select(Venue).where(Venue.id == venue_id)
    result = await db.session.execute(query)
    venue = result.scalars().first()
    return VenueItem(
        id=venue.id,
        name=venue.name,
        description=venue.description,
        address=venue.address,
        city=venue.city,
    )
  • Here we define two routes with help of APIRouter from fastapi library. Documentation here
    • first route is / and it returns all venues
    • second route is /{venue_id} and it returns venue by provided id
  • Note that we query database with SQLAlchemy 2.0 which is pretty similar to actual SQL.
  • Also notice that we obtain db.session from fastapi_async_sqlalchemy with help of middleware we added earlier to our create_app in app.py function.

Now lets expos booking model. Navigate to server/src/api/endpoints and create booking.py file. Add following code:

from typing import List
from fastapi import APIRouter, HTTPException, Request, Response
from sqlalchemy import select
from fastapi_async_sqlalchemy import db
from aiogram.utils.web_app import safe_parse_webapp_init_data
from aiogram.types import InlineQueryResultArticle, InputTextMessageContent

from src.bot import bot
from src.config import settings
from src.models import Booking, Venue
from src.schemas.booking import BookingItem, BookingCreate


router = APIRouter()


@router.get("/{venue_id}")
async def get_bookings(venue_id: int) -> List[BookingItem]:
    """Get bookings of a venue."""
    query = select(Booking).where(Booking.venue_id == venue_id)
    result = await db.session.execute(query)
    bookings = result.scalars().all()

    return bookings


@router.post("/{venue_id}", status_code=201)
async def book_venue(venue_id: int, request: Request):
    """Book a venue."""
    json_data = await request.json()

    # check if required fields are present
    required_fields = ["under_name", "date"]
    if not all(field in json_data for field in required_fields):
        raise HTTPException(status_code=400, detail="Missing required fields")

    # Check sent data validity
    try:
        web_app_init_data = safe_parse_webapp_init_data(
            token=settings.BOT_TOKEN, init_data=json_data.get("_auth")
        )
    except ValueError:
        return HTTPException(status_code=401, detail="Unauthorized")

    # Check if venue exists
    query = select(Venue).where(Venue.id == venue_id)
    result = await db.session.execute(query)
    venue = result.scalar_one_or_none()
    if not venue:
        raise HTTPException(status_code=404, detail="Venue not found")

    # Create booking
    user = web_app_init_data.user
    booking = BookingCreate(
        venue_id=venue_id,
        user_id=user.id,
        under_name=json_data.get("under_name"),
        date=json_data.get("date"),
        comment=json_data.get("comment")
    )
    db_obj = Booking(**booking.model_dump())

    db.session.add(db_obj)
    await db.session.commit()
    await db.session.refresh(db_obj)

    # Extract queryId
    query_id = web_app_init_data.query_id

    # Answer web app query
    confirm_message = f"Booking successful! 🎉\n\nDetails:\nVenue: {db_obj.venue.name}\nAddress: {db_obj.venue.address}, {db_obj.venue.city}\nUnder name: {db_obj.under_name}\nDate: {db_obj.date}\nComment: {db_obj.comment}"

    try:
        await bot.answer_web_app_query(
            web_app_query_id=query_id,
            result=InlineQueryResultArticle(
                type="article",
                id=query_id,
                title="Booking successful!",
                input_message_content=InputTextMessageContent(
                    message_text=confirm_message
                )
            )
        )

        return Response(status_code=201)
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))
  • Here we define two routes with help of APIRouter from fastapi library. Documentation here
    • first route is /{venue_id} and it returns all bookings for a venue
      • Here we just query the database and return all bookings. Note that we specify return schema with -> List[BookingItem]
    • second route is /{venue_id} and it creates a booking for a venue
      • Here we first get the json data passed to the request.
      • Then we check if all required fields are present. If not, we raise HTTPException with status code 400.
      • Then we check if the data is valid. We do this with help of safe_parse_webapp_init_data function from aiogram. (To validate data received via the Mini App, one should send the data from the Telegram.WebApp.initData field to the bot's backend). Documentation here
      • Next, we check if the venue exists. If not, we raise HTTPException with status code 404.
      • Finally, we create a booking and save it to the database. We also answer the web app query with help of bot.answer_web_app_query function from aiogram and query_id that we receive from frontend.

Finally, we have to add these routes to our application. To do this,

Let's create global API router. Navigate to server/src/api/api.py and add following code:

from fastapi import APIRouter

from src.api.endpoints import venue
from src.api.endpoints import booking

api_router = APIRouter()

api_router.include_router(venue.router, prefix="/venues", tags=["venues"])
api_router.include_router(booking.router, prefix="/bookings", tags=["bookings"])
  • Here we create global API router with APIRouter from fastapi library. Then, we add our endpoint routers with specific prefixes.

Finally, lets add this router to our app.

Now navigate to server/src/app.py and add our main API router:

from src.api.api import api_router

...

# add these to the bottom of the file
def register_app_routers(app: FastAPI):
    app.include_router(api_router)
  • Here we import our global API router and add it to our app with app.include_router(api_router)

That's it! We are ready to run our app. Make sure you edit necessary variables in .env files inside server and frontend folders.

To run the backend, navigate to server folder and run:

python main.py

To run the frontend, navigate to frontend folder and run:

npm run dev

Common Errors and Troubleshooting

NPM package related errors

  • if you get missing modules error when running the app, make sure you have installed all dependencies in frontend folder
    npm install
    

Python Related Errors

  • Virtualenv issues
    • Make sure you activate virtual environment before installing dependencies and running the app
    • If you get ModuleNotFoundError when running the app, make sure you are in virtual environment and you have installed all dependencies
  • Python Version issues
    • The project was developed with Python 3.10 but 3.8+ should work too
    • Make sure you have python 3.8+ installed
  • Also make sure you have installed everything from requirements.txt file
    pip install -r requirements.txt
    
    • These are base packages that I believe every FastAPI + Aiogram app will need.

Alembic migrations related errors

  • Migration conflicts and errors
    • Sometimes your migrations may conflict with each other. To fix this, you can delete sqlite database file and versions folder. After that just run migrations again.
      alembic revision --autogenerate -m "init"
      alembic upgrade head
      
  • Models aren't seen by alembic. No tables get created.
    • In this case make sure you inherit from PkBase model in your models.
    • Also if you define another base model, you should import it and any other model(s) in migrations/env.py file

CORS Related Errors

Cross-Origin Resource Sharing (CORS) is a security feature implemented by web browsers to protect against unauthorized requests from different domains. When developing a web application, you might encounter CORS issues when your front-end code, hosted on one domain, tries to make requests to an API or server on a different domain. To enable these cross-domain requests safely, you need to configure CORS settings in your FastAPI app.

For instance, in this example app I already specified CORS settings in server/src/app.py file. CORS origins will auto include FRONT_BASE_URL.

# backend/src/app.py

def create_app():
   # ...
   
   app.add_middleware(
     CORSMiddleware,
     allow_origins=[settings.FRONT_BASE_URL],  # Include your urls here to allow CORS issues
     allow_credentials=True,
     allow_methods=["*"],
     allow_headers=["*"],
   )
   
   # ...

Telegram Bot Related Errors

  • If you get Unauthorized error when trying to send messages to your bot, make sure you have specified the correct bot token in backend .env file

Other notes

  • To enable inspect for web app, press settings icon 5 times and turn on Debug Web App