Developing a Single Page App with FastAPI and Vue.js

Last updated December 14th, 2022

The following is a step-by-step walkthrough of how to build and containerize a basic CRUD app with FastAPI, Vue, Docker, and Postgres. We'll start in the backend, developing a RESTful API powered by Python, FastAPI, and Docker and then move on the frontend. We'll also wire up token-based authentication.

Final app:

final app

Main dependencies:

  • Vue v3.2.45
  • Vue CLI v5.0.8
  • Node v18.12.1
  • npm v8.19.2
  • FastAPI v0.88.0
  • Python v3.11.1

This is an intermediate-level tutorial, which focuses on developing backend and frontend apps with FastAPI and Vue, respectively. Along with the apps themselves, you'll add authentication and integrate them together. It's assumed that you have experience with FastAPI, Vue, and Docker. See the FastAPI and Vue section for recommended resources for learning the previously mentioned tools and technologies.

Contents

Objectives

By the end of this tutorial, you will be able to:

  1. Explain what FastAPI is
  2. Explain what Vue is and how it compares to other UI libraries and frontend frameworks like React and Angular
  3. Develop a RESTful API with FastAPI
  4. Scaffold a Vue project using the Vue CLI
  5. Create and render Vue components in the browser
  6. Create a Single Page Application (SPA) with Vue components
  7. Connect a Vue application to a FastAPI back-end
  8. Style Vue Components with Bootstrap
  9. Use the Vue Router to create routes and render components
  10. Manage user auth with token-based authentication

FastAPI and Vue

Let's quickly look at each framework.

What is FastAPI?

FastAPI is a modern, batteries-included Python web framework that's perfect for building RESTful APIs. It can handle both synchronous and asynchronous requests and has built-in support for data validation, JSON serialization, authentication and authorization, and OpenAPI documentation.

Highlights:

  1. Heavily inspired by Flask, it has a lightweight microframework feel with support for Flask-like route decorators.
  2. It takes advantage of Python type hints for parameter declaration which enables data validation (via pydantic) and OpenAPI/Swagger documentation.
  3. Built on top of Starlette, it supports the development of asynchronous APIs.
  4. It's fast. Since async is much more efficient than the traditional synchronous threading model, it can compete with Node and Go with regards to performance.
  5. Because it's based on and fully compatible with OpenAPI and JSON Schema, it supports a number of powerful tools, like Swagger UI.
  6. It has amazing documentation.

First time with FastAPI? Check out the following resources:

  1. Developing and Testing an Asynchronous API with FastAPI and Pytest
  2. Test-Driven Development with FastAPI and Docker

What is Vue?

Vue is an open-source JavaScript framework used for building user interfaces. It adopted some of the best practices from React and Angular. That said, compared to React and Angular, it's much more approachable, so beginners can get up and running quickly. It's also just as powerful, so it provides all the features you'll need to create modern front-end applications.

For more on Vue, along with the pros and cons of using it vs. React and Angular, review the following articles:

  1. Vue: Comparison with Other Frameworks
  2. Learn Vue by Building and Deploying a CRUD App
  3. React vs Angular vs Vue.js

First time with Vue?

  1. Take a moment to read through the Introduction from the official Vue guide.
  2. Check out the Learn Vue by Building and Deploying a CRUD App course as well.

What are we Building?

Our goal is to design a backend RESTful API, powered by Python and FastAPI, for two resources -- users and notes. The API itself should follow RESTful design principles, using the basic HTTP verbs: GET, POST, PUT, and DELETE.

We'll also set up a front-end application with Vue that interacts with the back-end API:

final app

Core functionality:

  1. Authenticated users will be able to view, add, update, and delete notes
  2. Authenticated users will also be able to view their user info and delete themselves

This tutorial mostly just deals with the happy path. Handling unhappy/exception paths is a separate exercise for the reader. Check your understanding and add proper error handling for both the frontend and backend.

FastAPI Setup

Start by creating a new project folder called "fastapi-vue" and add the following files and folders:

fastapi-vue
├── docker-compose.yml
└── services
    └── backend
        ├── Dockerfile
        ├── requirements.txt
        └── src
            └── main.py

The following command will create the project structure:

$ mkdir fastapi-vue && \
  cd fastapi-vue && \
  mkdir -p services/backend/src && \
  touch docker-compose.yml services/backend/Dockerfile && \
  touch services/backend/requirements.txt services/backend/src/main.py

Next, add the following code to services/backend/Dockerfile:

FROM python:3.11-buster

RUN mkdir app
WORKDIR /app

ENV PATH="${PATH}:/root/.local/bin"
ENV PYTHONPATH=.

COPY requirements.txt .
RUN pip install --upgrade pip
RUN pip install -r requirements.txt

COPY src/ .

Add the following dependencies to the services/backend/requirements.txt file:

fastapi==0.88.0
uvicorn==0.20.0

Update docker-compose.yml like so:

version: '3.8'

services:

  backend:
    build: ./services/backend
    ports:
      - 5000:5000
    volumes:
      - ./services/backend:/app
    command: uvicorn src.main:app --reload --host 0.0.0.0 --port 5000

Before we build the image, let's add a test route to services/backend/src/main.py so we can quickly test that the app was built successfully:

from fastapi import FastAPI


app = FastAPI()


@app.get("/")
def home():
    return "Hello, World!"

Build the image in your terminal:

$ docker-compose up -d --build

Once done, navigate to http://127.0.0.1:5000/ in your browser of choice. You should see:

"Hello, World!"

You can view the Swagger UI at http://localhost:5000/docs.

Next, add CORSMiddleware:

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


app = FastAPI()

# NEW
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:8080"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.get("/")
def home():
    return "Hello, World!"

CORSMiddleware is required to make cross-origin requests -- i.e., requests that originate from a different protocol, IP address, domain name, or port. This is necessary since the frontend will run at http://localhost:8080.

Vue Setup

To get started with our frontend, we'll scaffold out a project using the Vue CLI.

Make sure you're using version 5.0.8 of the Vue CLI:

$ vue -V
@vue/cli 5.0.8

# install
$ npm install -g @vue/[email protected]

Next, from the "fastapi-vue/services" folder, scaffold out a new Vue project:

$ vue create frontend

Select Default ([Vue 3] babel, eslint).

After the scaffold is up, add the router (say yes to history mode), and install the required dependencies:

$ cd frontend
$ vue add router
$ Use history mode for router ? Y
$ npm install --save [email protected] [email protected] [email protected]

We'll discuss each of these dependencies shortly.

To serve up the Vue application locally, run:

$ npm run serve

Navigate to http://localhost:8080/ to view your app.

Kill the server.

Next, wire up the dependencies for Axios and Bootstrap in services/frontend/src/main.js:

import 'bootstrap/dist/css/bootstrap.css';
import { createApp } from "vue";
import axios from 'axios';

import App from './App.vue';
import router from './router';

const app = createApp(App);

axios.defaults.withCredentials = true;
axios.defaults.baseURL = 'http://localhost:5000/';  // the FastAPI backend

app.use(router);
app.mount("#app");

Add a Dockerfile to "services/frontend":

FROM node:lts-alpine

WORKDIR /app

ENV PATH /app/node_modules/.bin:$PATH

RUN npm install @vue/[email protected] -g

COPY package.json .
COPY package-lock.json .
RUN npm install

CMD ["npm", "run", "serve"]

Add a frontend service to docker-compose.yml:

version: '3.8'

services:

  backend:
    build: ./services/backend
    ports:
      - 5000:5000
    volumes:
      - ./services/backend:/app
    command: uvicorn src.main:app --reload --host 0.0.0.0 --port 5000

  frontend:
    build: ./services/frontend
    volumes:
      - './services/frontend:/app'
      - '/app/node_modules'
    ports:
      - 8080:8080

Build the new image and spin up the containers:

$ docker-compose up -d --build

Ensure http://localhost:8080/ still works.

Next, update services/frontend/src/components/HelloWorld.vue like so:

<template>
  <div>
    <p>{{ msg }}</p>
  </div>
</template>

<script>
import axios from 'axios';

export default {
  name: 'Ping',
  data() {
    return {
      msg: '',
    };
  },
  methods: {
    getMessage() {
      axios.get('/')
        .then((res) => {
          this.msg = res.data;
        })
        .catch((error) => {
          console.error(error);
        });
    },
  },
  created() {
    this.getMessage();
  },
};
</script>

Axios, which is an HTTP client, is used to send AJAX requests to the backend. In the above component, we updated the value of msg from the response from the backend.

Finally, within services/frontend/src/App.vue, remove the navigation along with the associated styles:

<template>
  <div id="app">
    <router-view/>
  </div>
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}
</style>

You should now see Hello, World! in the browser at http://localhost:8080/.

Your full project structure should now look like this:

├── docker-compose.yml
└── services
    ├── backend
    │   ├── Dockerfile
    │   ├── requirements.txt
    │   └── src
    │       └── main.py
    └── frontend
        ├── .gitignore
        ├── Dockerfile
        ├── README.md
        ├── babel.config.js
        ├── jsconfig.json
        ├── package-lock.json
        ├── package.json
        ├── public
        │   ├── favicon.ico
        │   └── index.html
        ├── src
        │   ├── App.vue
        │   ├── assets
        │   │   └── logo.png
        │   ├── components
        │   │   └── HelloWorld.vue
        │   ├── main.js
        │   ├── router
        │   │   └── index.js
        │   └── views
        │       ├── AboutView.vue
        │       └── HomeView.vue
        └── vue.config.js

Models and Migrations

We'll be using Tortoise for our ORM (Object Relational Mapper) and Aerich for managing database migrations.

Update the backend dependencies:

aerich==0.7.1
asyncpg==0.27.0
fastapi==0.88.0
tortoise-orm==0.19.2
uvicorn==0.20.0

First, let's add a new service for Postgres to docker-compose.yml:

version: '3.8'

services:

  backend:
    build: ./services/backend
    ports:
      - 5000:5000
    environment:
      - DATABASE_URL=postgres://hello_fastapi:hello_fastapi@db:5432/hello_fastapi_dev
    volumes:
      - ./services/backend:/app
    command: uvicorn src.main:app --reload --host 0.0.0.0 --port 5000
    depends_on:
      - db

  frontend:
    build: ./services/frontend
    volumes:
      - './services/frontend:/app'
      - '/app/node_modules'
    ports:
      - 8080:8080

  db:
    image: postgres:15.1
    expose:
      - 5432
    environment:
      - POSTGRES_USER=hello_fastapi
      - POSTGRES_PASSWORD=hello_fastapi
      - POSTGRES_DB=hello_fastapi_dev
    volumes:
      - postgres_data:/var/lib/postgresql/data/

volumes:
  postgres_data:

Take note of the environment variables in db along with the new DATABASE_URL environment variable in the backend service.

Next, create a folder called "database" in the "services/backend/src" folder, and a new file called models.py to it:

from tortoise import fields, models


class Users(models.Model):
    id = fields.IntField(pk=True)
    username = fields.CharField(max_length=20, unique=True)
    full_name = fields.CharField(max_length=50, null=True)
    password = fields.CharField(max_length=128, null=True)
    created_at = fields.DatetimeField(auto_now_add=True)
    modified_at = fields.DatetimeField(auto_now=True)


class Notes(models.Model):
    id = fields.IntField(pk=True)
    title = fields.CharField(max_length=225)
    content = fields.TextField()
    author = fields.ForeignKeyField("models.Users", related_name="note")
    created_at = fields.DatetimeField(auto_now_add=True)
    modified_at = fields.DatetimeField(auto_now=True)

    def __str__(self):
        return f"{self.title}, {self.author_id} on {self.created_at}"

The Users and Notes classes will create two new tables in our database. Take note that the author column relates back to the user, creating a one-to-many relationship (one user can have many notes).

Create a config.py file in the "services/backend/src/database" folder:

import os


TORTOISE_ORM = {
    "connections": {"default": os.environ.get("DATABASE_URL")},
    "apps": {
        "models": {
            "models": [
                "src.database.models", "aerich.models"
            ],
            "default_connection": "default"
        }
    }
}

Here, we specified the configuration for both Tortoise and Aerich.

Put simply, we:

  1. Defined the database connection via the DATABASE_URL environment variable
  2. Registered our models, src.database.models (users and notes) and aerich.models (migration metadata)

Add a register.py file to "services/backend/src/database" as well:

from typing import Optional

from tortoise import Tortoise


def register_tortoise(
    app,
    config: Optional[dict] = None,
    generate_schemas: bool = False,
) -> None:
    @app.on_event("startup")
    async def init_orm():
        await Tortoise.init(config=config)
        if generate_schemas:
            await Tortoise.generate_schemas()

    @app.on_event("shutdown")
    async def close_orm():
        await Tortoise.close_connections()

register_tortoise is a function that will be used for configuring our application and models with Tortoise. It takes in our app, a config dict, and a generate_schema boolean.

The function will be called in main.py with our config dict:

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

from src.database.register import register_tortoise  # NEW
from src.database.config import TORTOISE_ORM         # NEW


app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# NEW
register_tortoise(app, config=TORTOISE_ORM, generate_schemas=False)


@app.get("/")
def home():
    return "Hello, World!"

Build the new images and spin up the containers:

$ docker-compose up -d --build

After the containers are up and running, run:

$ docker-compose exec backend aerich init -t src.database.config.TORTOISE_ORM
Success create migrate location ./migrations
Success write config to pyproject.toml

$ docker-compose exec backend aerich init-db
Success create app migrate location migrations/models
Success generate schema for app "models"

The first command told Aerich where the config dict is for initializing the connection between the models and the database. This created a services/backend/pyproject.toml config file and a "services/backend/migrations" folder.

Next, we generated a migration file for our three models -- users, notes, and aerich -- inside "services/backend/migrations/models". These were applied to the database as well.

Let's copy the pyproject.toml file and "migrations" folder to the container. To do so, update the Dockerfile like so:

FROM python:3.11-buster

RUN mkdir app
WORKDIR /app

ENV PATH="${PATH}:/root/.local/bin"
ENV PYTHONPATH=.

COPY requirements.txt .
RUN pip install --upgrade pip
RUN pip install -r requirements.txt

# for migrations
COPY migrations .
COPY pyproject.toml .

COPY src/ .

Update:

$ docker-compose up -d --build

Now, when you make changes to the models, you can run the following commands to update the database:

$ docker-compose exec backend aerich migrate
$ docker-compose exec backend aerich upgrade

CRUD Actions

Now let's wire up the basic CRUD actions: create, read, update, and delete.

First, since we need to define schemas for serializing and deserializing our data, create two folders in "services/backend/src" called "crud" and "schemas".

To ensure our serializers can read the relationship between our models, we need to initialize the models in the main.py file:

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from tortoise import Tortoise  # NEW

from src.database.register import register_tortoise
from src.database.config import TORTOISE_ORM


# enable schemas to read relationship between models
Tortoise.init_models(["src.database.models"], "models")  # NEW

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:8080"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

register_tortoise(app, config=TORTOISE_ORM, generate_schemas=False)


@app.get("/")
def home():
    return "Hello, World!"

Now, queries made on any object can get the data from the related table.

Next, in the "schemas" folder, add two files called users.py and notes.py.

services/backend/src/schemas/users.py:

from tortoise.contrib.pydantic import pydantic_model_creator

from src.database.models import Users


UserInSchema = pydantic_model_creator(
    Users, name="UserIn", exclude_readonly=True
)
UserOutSchema = pydantic_model_creator(
    Users, name="UserOut", exclude=["password", "created_at", "modified_at"]
)
UserDatabaseSchema = pydantic_model_creator(
    Users, name="User", exclude=["created_at", "modified_at"]
)

pydantic_model_creator is a Tortoise helper that allows us to create pydantic models from Tortoise models, which we'll use to create and retrieve database records. It takes in the Users model and a name. You can also exclude specific columns.

Schemas:

  1. UserInSchema is for creating new users.
  2. UserOutSchema is for retrieving user info to be used outside our application, for returning to end users.
  3. UserDatabaseSchema is for retrieving user info to be used within our application, for validating users.

services/backend/src/schemas/notes.py:

from typing import Optional

from pydantic import BaseModel
from tortoise.contrib.pydantic import pydantic_model_creator

from src.database.models import Notes


NoteInSchema = pydantic_model_creator(
    Notes, name="NoteIn", exclude=["author_id"], exclude_readonly=True)
NoteOutSchema = pydantic_model_creator(
    Notes, name="Note", exclude =[
      "modified_at", "author.password", "author.created_at", "author.modified_at"
    ]
)


class UpdateNote(BaseModel):
    title: Optional[str]
    content: Optional[str]

Schemas:

  1. NoteInSchema is for creating new notes.
  2. NoteOutSchema is for retrieving notes.
  3. UpdateNote is for updating notes.

Next, add users.py and notes.py files to the "services/backend/src/crud" folder.

services/backend/src/crud/users.py:

from fastapi import HTTPException
from passlib.context import CryptContext
from tortoise.exceptions import DoesNotExist, IntegrityError

from src.database.models import Users
from src.schemas.users import UserOutSchema


pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


async def create_user(user) -> UserOutSchema:
    user.password = pwd_context.encrypt(user.password)

    try:
        user_obj = await Users.create(**user.dict(exclude_unset=True))
    except IntegrityError:
        raise HTTPException(status_code=401, detail=f"Sorry, that username already exists.")

    return await UserOutSchema.from_tortoise_orm(user_obj)


async def delete_user(user_id, current_user):
    try:
        db_user = await UserOutSchema.from_queryset_single(Users.get(id=user_id))
    except DoesNotExist:
        raise HTTPException(status_code=404, detail=f"User {user_id} not found")

    if db_user.id == current_user.id:
        deleted_count = await Users.filter(id=user_id).delete()
        if not deleted_count:
            raise HTTPException(status_code=404, detail=f"User {user_id} not found")
        return f"Deleted user {user_id}"

    raise HTTPException(status_code=403, detail=f"Not authorized to delete")

Here, we defined helper functions for creating and deleting users:

  1. create_user takes in a user, encrypts user.password, and then adds the user to the database.
  2. delete_user deletes a user from the database. It also protects the users by ensuring the request is initiated by a currently authenticated user.

Add the required dependencies to services/backend/requirements.txt:

aerich==0.7.1
asyncpg==0.27.0
bcrypt==4.0.1
passlib==1.7.4
fastapi==0.88.0
tortoise-orm==0.19.2
uvicorn==0.20.0

services/backend/src/crud/notes.py:

from fastapi import HTTPException
from tortoise.exceptions import DoesNotExist

from src.database.models import Notes
from src.schemas.notes import NoteOutSchema


async def get_notes():
    return await NoteOutSchema.from_queryset(Notes.all())


async def get_note(note_id) -> NoteOutSchema:
    return await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))


async def create_note(note, current_user) -> NoteOutSchema:
    note_dict = note.dict(exclude_unset=True)
    note_dict["author_id"] = current_user.id
    note_obj = await Notes.create(**note_dict)
    return await NoteOutSchema.from_tortoise_orm(note_obj)


async def update_note(note_id, note, current_user) -> NoteOutSchema:
    try:
        db_note = await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))
    except DoesNotExist:
        raise HTTPException(status_code=404, detail=f"Note {note_id} not found")

    if db_note.author.id == current_user.id:
        await Notes.filter(id=note_id).update(**note.dict(exclude_unset=True))
        return await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))

    raise HTTPException(status_code=403, detail=f"Not authorized to update")


async def delete_note(note_id, current_user):
    try:
        db_note = await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))
    except DoesNotExist:
        raise HTTPException(status_code=404, detail=f"Note {note_id} not found")

    if db_note.author.id == current_user.id:
        deleted_count = await Notes.filter(id=note_id).delete()
        if not deleted_count:
            raise HTTPException(status_code=404, detail=f"Note {note_id} not found")
        return f"Deleted note {note_id}"

    raise HTTPException(status_code=403, detail=f"Not authorized to delete")

Here, we created helper functions for implementing all the CRUD actions for the notes resource. Take note of the update_note and delete_note helpers. We added a check to ensure that the request is coming from the note author.

Your folder structure should now look like this:

├── docker-compose.yml
└── services
    ├── backend
    │   ├── Dockerfile
    │   ├── migrations
    │   │   └── models
    │   │       └── 0_20221212182213_init.py
    │   ├── pyproject.toml
    │   ├── requirements.txt
    │   └── src
    │       ├── crud
    │       │   ├── notes.py
    │       │   └── users.py
    │       ├── database
    │       │   ├── config.py
    │       │   ├── models.py
    │       │   └── register.py
    │       ├── main.py
    │       └── schemas
    │           ├── notes.py
    │           └── users.py
    └── frontend
        ├── .gitignore
        ├── Dockerfile
        ├── README.md
        ├── babel.config.js
        ├── jsconfig.json
        ├── package-lock.json
        ├── package.json
        ├── public
        │   ├── favicon.ico
        │   └── index.html
        ├── src
        │   ├── App.vue
        │   ├── assets
        │   │   └── logo.png
        │   ├── components
        │   │   └── HelloWorld.vue
        │   ├── main.js
        │   ├── router
        │   │   └── index.js
        │   └── views
        │       ├── AboutView.vue
        │       └── HomeView.vue
        └── vue.config.js

This is a good time to stop, review what you've accomplished thus far, and wire up pytest to test the CRUD helpers. Need help? Review Developing and Testing an Asynchronous API with FastAPI and Pytest.

JWT Authentication

Before we add the route handlers, let's wire up authentication to protect specific routes.

To start, we need to create a few pydantic models in a new file called token.py in the "services/backend/src/schemas" folder:

from typing import Optional

from pydantic import BaseModel


class TokenData(BaseModel):
    username: Optional[str] = None


class Status(BaseModel):
    message: str

We defined two schemas:

  1. TokenData is for ensuring the username from the token is a string.
  2. Status is for sending status messages back to the end user.

Create another folder called "auth" in the "services/backend/src" folder. Then, add two new files to it as well called jwthandler.py and users.py.

services/backend/src/auth/jwthandler.py:

import os
from datetime import datetime, timedelta
from typing import Optional

from fastapi import Depends, HTTPException, Request
from fastapi.openapi.models import OAuthFlows as OAuthFlowsModel
from fastapi.security import OAuth2
from fastapi.security.utils import get_authorization_scheme_param
from jose import JWTError, jwt
from tortoise.exceptions import DoesNotExist

from src.schemas.token import TokenData
from src.schemas.users import UserOutSchema
from src.database.models import Users


SECRET_KEY = os.environ.get("SECRET_KEY")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30


class OAuth2PasswordBearerCookie(OAuth2):
    def __init__(
        self,
        token_url: str,
        scheme_name: str = None,
        scopes: dict = None,
        auto_error: bool = True,
    ):
        if not scopes:
            scopes = {}
        flows = OAuthFlowsModel(password={"tokenUrl": token_url, "scopes": scopes})
        super().__init__(flows=flows, scheme_name=scheme_name, auto_error=auto_error)

    async def __call__(self, request: Request) -> Optional[str]:
        authorization: str = request.cookies.get("Authorization")
        scheme, param = get_authorization_scheme_param(authorization)

        if not authorization or scheme.lower() != "bearer":
            if self.auto_error:
                raise HTTPException(
                    status_code=401,
                    detail="Not authenticated",
                    headers={"WWW-Authenticate": "Bearer"},
                )
            else:
                return None

        return param


security = OAuth2PasswordBearerCookie(token_url="/login")


def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
    to_encode = data.copy()

    if expires_delta:
        expire = datetime.utcnow() + expires_delta
    else:
        expire = datetime.utcnow() + timedelta(minutes=15)

    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

    return encoded_jwt


async def get_current_user(token: str = Depends(security)):
    credentials_exception = HTTPException(
        status_code=401,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )

    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username: str = payload.get("sub")
        if username is None:
            raise credentials_exception
        token_data = TokenData(username=username)
    except JWTError:
        raise credentials_exception

    try:
        user = await UserOutSchema.from_queryset_single(
            Users.get(username=token_data.username)
        )
    except DoesNotExist:
        raise credentials_exception

    return user

Notes:

  1. OAuth2PasswordBearerCookie is a class that inherits from the OAuth2 class that is used for reading the cookie sent in the request header for protected routes. It ensures that the cookie is present and then returns the token from the cookie.
  2. The create_access_token function takes in the user's username, encodes it with the expiring time, and generates a token from it.
  3. get_current_user decodes the token and validates the user.

python-jose is used for encoding and decoding the JWT token. Add the package to the requirements file:

aerich==0.7.1
asyncpg==0.27.0
bcrypt==4.0.1
passlib==1.7.4
fastapi==0.88.0
python-jose==3.3.0
tortoise-orm==0.19.2
uvicorn==0.20.0

Add the SECRET_KEY environment variable to docker-compose.yml:

version: '3.8'

services:

  backend:
    build: ./services/backend
    ports:
      - 5000:5000
    environment:
      - DATABASE_URL=postgres://hello_fastapi:hello_fastapi@db:5432/hello_fastapi_dev
      - SECRET_KEY=09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7
    volumes:
      - ./services/backend:/app
    command: uvicorn src.main:app --reload --host 0.0.0.0 --port 5000
    depends_on:
      - db

  frontend:
    build: ./services/frontend
    volumes:
      - './services/frontend:/app'
      - '/app/node_modules'
    ports:
      - 8080:8080

  db:
    image: postgres:15.1
    expose:
      - 5432
    environment:
      - POSTGRES_USER=hello_fastapi
      - POSTGRES_PASSWORD=hello_fastapi
      - POSTGRES_DB=hello_fastapi_dev
    volumes:
      - postgres_data:/var/lib/postgresql/data/

volumes:
  postgres_data:

services/backend/src/auth/users.py:

from fastapi import HTTPException, Depends, status
from fastapi.security import OAuth2PasswordRequestForm
from passlib.context import CryptContext
from tortoise.exceptions import DoesNotExist

from src.database.models import Users
from src.schemas.users import UserDatabaseSchema


pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def verify_password(plain_password, hashed_password):
    return pwd_context.verify(plain_password, hashed_password)


def get_password_hash(password):
    return pwd_context.hash(password)


async def get_user(username: str):
    return await UserDatabaseSchema.from_queryset_single(Users.get(username=username))


async def validate_user(user: OAuth2PasswordRequestForm = Depends()):
    try:
        db_user = await get_user(user.username)
    except DoesNotExist:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
        )

    if not verify_password(user.password, db_user.password):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
        )

    return db_user

Notes:

  • validate_user is used in to verify a user when they log in. If either the username or password are incorrect, it throws a 401_UNAUTHORIZED error back to the user.

Finally, let's update the CRUD helpers so that they use the Status pydantic model:

class Status(BaseModel):
    message: str

services/backend/src/crud/users.py:

from fastapi import HTTPException
from passlib.context import CryptContext
from tortoise.exceptions import DoesNotExist, IntegrityError

from src.database.models import Users
from src.schemas.token import Status  # NEW
from src.schemas.users import UserOutSchema


pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


async def create_user(user) -> UserOutSchema:
    user.password = pwd_context.encrypt(user.password)

    try:
        user_obj = await Users.create(**user.dict(exclude_unset=True))
    except IntegrityError:
        raise HTTPException(status_code=401, detail=f"Sorry, that username already exists.")

    return await UserOutSchema.from_tortoise_orm(user_obj)


async def delete_user(user_id, current_user) -> Status:  # UPDATED
    try:
        db_user = await UserOutSchema.from_queryset_single(Users.get(id=user_id))
    except DoesNotExist:
        raise HTTPException(status_code=404, detail=f"User {user_id} not found")

    if db_user.id == current_user.id:
        deleted_count = await Users.filter(id=user_id).delete()
        if not deleted_count:
            raise HTTPException(status_code=404, detail=f"User {user_id} not found")
        return Status(message=f"Deleted user {user_id}")  # UPDATED

    raise HTTPException(status_code=403, detail=f"Not authorized to delete")

services/backend/src/crud/notes.py:

from fastapi import HTTPException
from tortoise.exceptions import DoesNotExist

from src.database.models import Notes
from src.schemas.notes import NoteOutSchema
from src.schemas.token import Status  # NEW


async def get_notes():
    return await NoteOutSchema.from_queryset(Notes.all())


async def get_note(note_id) -> NoteOutSchema:
    return await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))


async def create_note(note, current_user) -> NoteOutSchema:
    note_dict = note.dict(exclude_unset=True)
    note_dict["author_id"] = current_user.id
    note_obj = await Notes.create(**note_dict)
    return await NoteOutSchema.from_tortoise_orm(note_obj)


async def update_note(note_id, note, current_user) -> NoteOutSchema:
    try:
        db_note = await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))
    except DoesNotExist:
        raise HTTPException(status_code=404, detail=f"Note {note_id} not found")

    if db_note.author.id == current_user.id:
        await Notes.filter(id=note_id).update(**note.dict(exclude_unset=True))
        return await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))

    raise HTTPException(status_code=403, detail=f"Not authorized to update")


async def delete_note(note_id, current_user) -> Status:  # UPDATED
    try:
        db_note = await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))
    except DoesNotExist:
        raise HTTPException(status_code=404, detail=f"Note {note_id} not found")

    if db_note.author.id == current_user.id:
        deleted_count = await Notes.filter(id=note_id).delete()
        if not deleted_count:
            raise HTTPException(status_code=404, detail=f"Note {note_id} not found")
        return Status(message=f"Deleted note {note_id}")  # UPDATED

    raise HTTPException(status_code=403, detail=f"Not authorized to delete")

Routing

With the pydantic models, CRUD helpers, and JWT authentication set up, we can now glue everything together with the route handlers.

Create a "routes" folder in our "src" folder and add two files, users.py and notes.py.

users.py:

from datetime import timedelta

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from fastapi.security import OAuth2PasswordRequestForm

from tortoise.contrib.fastapi import HTTPNotFoundError

import src.crud.users as crud
from src.auth.users import validate_user
from src.schemas.token import Status
from src.schemas.users import UserInSchema, UserOutSchema

from src.auth.jwthandler import (
    create_access_token,
    get_current_user,
    ACCESS_TOKEN_EXPIRE_MINUTES,
)


router = APIRouter()


@router.post("/register", response_model=UserOutSchema)
async def create_user(user: UserInSchema) -> UserOutSchema:
    return await crud.create_user(user)


@router.post("/login")
async def login(user: OAuth2PasswordRequestForm = Depends()):
    user = await validate_user(user)

    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )

    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_access_token(
        data={"sub": user.username}, expires_delta=access_token_expires
    )
    token = jsonable_encoder(access_token)
    content = {"message": "You've successfully logged in. Welcome back!"}
    response = JSONResponse(content=content)
    response.set_cookie(
        "Authorization",
        value=f"Bearer {token}",
        httponly=True,
        max_age=1800,
        expires=1800,
        samesite="Lax",
        secure=False,
    )

    return response


@router.get(
    "/users/whoami", response_model=UserOutSchema, dependencies=[Depends(get_current_user)]
)
async def read_users_me(current_user: UserOutSchema = Depends(get_current_user)):
    return current_user


@router.delete(
    "/user/{user_id}",
    response_model=Status,
    responses={404: {"model": HTTPNotFoundError}},
    dependencies=[Depends(get_current_user)],
)
async def delete_user(
    user_id: int, current_user: UserOutSchema = Depends(get_current_user)
) -> Status:
    return await crud.delete_user(user_id, current_user)

What's happening here?

  1. get_current_user is attached to read_users_me and delete_user in order to protect the routes. Unless the user is logged in as current_user, they won't be able to access them.
  2. /register leverages the crud.create_user helper to create a new user and add it to the database.
  3. /login takes in a user via form data from OAuth2PasswordRequestForm containing the username and password. It then calls the validate_user function with the user or throws an exception if None. An access token is generated from the create_access_token function and then attached to the response header as a cookie.
  4. /users/whoami takes in get_current_user and sends back the results as a response.
  5. /user/{user_id} is a dynamic route that takes in the user_id and sends it to the crud.delete_user helper with the results from current_user.

OAuth2PasswordRequestForm requires Python-Multipart. Add it to services/backend/requirements.txt:

aerich==0.7.1
asyncpg==0.27.0
bcrypt==4.0.1
passlib==1.7.4
fastapi==0.88.0
python-jose==3.3.0
python-multipart==0.0.5
tortoise-orm==0.19.2
uvicorn==0.20.0

After users successfully authenticate, a cookie is sent back, via Set-Cookie, in the response header. When users make subsequent requests, it's attached to the request header.

Take note of:

response.set_cookie(
    "Authorization",
    value=f"Bearer {token}",
    httponly=True,
    max_age=1800,
    expires=1800,
    samesite="Lax",
    secure=False,
)

Notes:

  1. The name of the cookie is Authorization with a value of Bearer {token}, with token being the actual token. It expires after 1800 seconds (30 minutes).
  2. httponly is set to True for security purposes so that client-side scripts won't be able to access the cookie. This helps prevent Cross Site Scripting (XSS) attacks.
  3. With samesite set to Lax, the browser only sends cookies on some HTTP requests. This helps prevent Cross Site Request Forgery (CSRF) attacks.
  4. Finally, secure is set to False since we'll be testing locally, without HTTPS. Make sure to set this to True in production.

notes.py:

from typing import List

from fastapi import APIRouter, Depends, HTTPException
from tortoise.contrib.fastapi import HTTPNotFoundError
from tortoise.exceptions import DoesNotExist

import src.crud.notes as crud
from src.auth.jwthandler import get_current_user
from src.schemas.notes import NoteOutSchema, NoteInSchema, UpdateNote
from src.schemas.token import Status
from src.schemas.users import UserOutSchema


router = APIRouter()


@router.get(
    "/notes",
    response_model=List[NoteOutSchema],
    dependencies=[Depends(get_current_user)],
)
async def get_notes():
    return await crud.get_notes()


@router.get(
    "/note/{note_id}",
    response_model=NoteOutSchema,
    dependencies=[Depends(get_current_user)],
)
async def get_note(note_id: int) -> NoteOutSchema:
    try:
        return await crud.get_note(note_id)
    except DoesNotExist:
        raise HTTPException(
            status_code=404,
            detail="Note does not exist",
        )


@router.post(
    "/notes", response_model=NoteOutSchema, dependencies=[Depends(get_current_user)]
)
async def create_note(
    note: NoteInSchema, current_user: UserOutSchema = Depends(get_current_user)
) -> NoteOutSchema:
    return await crud.create_note(note, current_user)


@router.patch(
    "/note/{note_id}",
    dependencies=[Depends(get_current_user)],
    response_model=NoteOutSchema,
    responses={404: {"model": HTTPNotFoundError}},
)
async def update_note(
    note_id: int,
    note: UpdateNote,
    current_user: UserOutSchema = Depends(get_current_user),
) -> NoteOutSchema:
    return await crud.update_note(note_id, note, current_user)


@router.delete(
    "/note/{note_id}",
    response_model=Status,
    responses={404: {"model": HTTPNotFoundError}},
    dependencies=[Depends(get_current_user)],
)
async def delete_note(
    note_id: int, current_user: UserOutSchema = Depends(get_current_user)
):
    return await crud.delete_note(note_id, current_user)

Review this on your own.

Finally, we need to wire up our routes in main.py:

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

from src.database.register import register_tortoise
from src.database.config import TORTOISE_ORM


# enable schemas to read relationship between models
Tortoise.init_models(["src.database.models"], "models")

"""
import 'from src.routes import users, notes' must be after 'Tortoise.init_models'
why?
https://stackoverflow.com/questions/65531387/tortoise-orm-for-python-no-returns-relations-of-entities-pyndantic-fastapi
"""
from src.routes import users, notes

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:8080"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)
app.include_router(users.router)
app.include_router(notes.router)

register_tortoise(app, config=TORTOISE_ORM, generate_schemas=False)


@app.get("/")
def home():
    return "Hello, World!"

Update the images to install the new dependencies:

$ docker-compose up -d --build

Navigate to http://localhost:5000/docs to view the Swagger UI:

Swagger UI

You can now manually test each route.

What should you test?

Route Method Happy Path Unhappy Path(s)
/register POST You can register a new user Duplicate username, missing username or password fields
/login POST You can log a user in Incorrect username or password
/users/whoami GET Returns user info when authenticated No Authorization cookie or invalid token
/user/{user_id} DELETE You can delete a user when authenticated and you're trying to delete the current user User not found, user exists but not authorized to delete
/notes GET You can get all notes when authenticated Not authenticated
/notes POST You can add a note when authenticated Not authenticated
/note/{note_id} GET You can get the note when authenticated and it exists Not authenticated, authenticated but the note doesn't exist
/note/{note_id} DELETE You can delete the note when authenticated, the note exists, and the current user created the note Not authenticated, authenticated but the note doesn't exist, not exists but not authorized to delete
/note/{note_id} PATCH You can update the note when authenticated, the note exists, and the current user created the note Not authenticated, authenticated but the note doesn't exist, not exists but not authorized to update

That's a lot of tedious manual testing. It's a good idea to add automated tests with pytest. Again, review Developing and Testing an Asynchronous API with FastAPI and Pytest for help with this.

With that, let's turn our attention to the frontend.

Vuex

Vuex is Vue's state management pattern and library. It manages state globally. In Vuex, mutations, which are called by actions, are used to change state.

Add a new folder to "services/frontend/src" called "store". Within "store", add the following files and folders:

services/frontend/src/store
├── index.js
└── modules
    ├── notes.js
    └── users.js

services/frontend/src/store/index.js:

import { createStore } from "vuex";

import notes from './modules/notes';
import users from './modules/users';

export default createStore({
  modules: {
    notes,
    users,
  }
});

Here, we created a new Vuex Store with two modules, notes.js and users.js.

services/frontend/src/store/modules/notes.js:

import axios from 'axios';

const state = {
  notes: null,
  note: null
};

const getters = {
  stateNotes: state => state.notes,
  stateNote: state => state.note,
};

const actions = {
  async createNote({dispatch}, note) {
    await axios.post('notes', note);
    await dispatch('getNotes');
  },
  async getNotes({commit}) {
    let {data} = await axios.get('notes');
    commit('setNotes', data);
  },
  async viewNote({commit}, id) {
    let {data} = await axios.get(`note/${id}`);
    commit('setNote', data);
  },
  // eslint-disable-next-line no-empty-pattern
  async updateNote({}, note) {
    await axios.patch(`note/${note.id}`, note.form);
  },
  // eslint-disable-next-line no-empty-pattern
  async deleteNote({}, id) {
    await axios.delete(`note/${id}`);
  }
};

const mutations = {
  setNotes(state, notes){
    state.notes = notes;
  },
  setNote(state, note){
    state.note = note;
  },
};

export default {
  state,
  getters,
  actions,
  mutations
};

Notes:

  1. state - both note and notes default to null. They'll be updated to an object and an array of objects, respectively.
  2. getters - retrieves the values of state.note and state.notes.
  3. actions - each of the actions make an HTTP call via Axios and then a few of them perform a side effect -- i.e, call the relevant mutation to update state or a different action.
  4. mutations - both make changes to the state, which update state.note and state.notes.

services/frontend/src/store/modules/users.js:

import axios from 'axios';

const state = {
  user: null,
};

const getters = {
  isAuthenticated: state => !!state.user,
  stateUser: state => state.user,
};

const actions = {
  async register({dispatch}, form) {
    await axios.post('register', form);
    let UserForm = new FormData();
    UserForm.append('username', form.username);
    UserForm.append('password', form.password);
    await dispatch('logIn', UserForm);
  },
  async logIn({dispatch}, user) {
    await axios.post('login', user);
    await dispatch('viewMe');
  },
  async viewMe({commit}) {
    let {data} = await axios.get('users/whoami');
    await commit('setUser', data);
  },
  // eslint-disable-next-line no-empty-pattern
  async deleteUser({}, id) {
    await axios.delete(`user/${id}`);
  },
  async logOut({commit}) {
    let user = null;
    commit('logout', user);
  }
};

const mutations = {
  setUser(state, username) {
    state.user = username;
  },
  logout(state, user){
    state.user = user;
  },
};

export default {
  state,
  getters,
  actions,
  mutations
};

Notes:

  1. isAuthenticated - returns true if state.user is not null and false otherwise.
  2. stateUser - returns the value of state.user.
  3. register - sends a POST request to the /register endpoint we created in the backend, creates a FormData instance, and dispatches it to the logIn action to log the registered user in.

Finally, wire up the store to the root instance in services/frontend/src/main.js:

import 'bootstrap/dist/css/bootstrap.css';
import { createApp } from "vue";
import axios from 'axios';

import App from './App.vue';
import router from './router';
import store from './store'; // New

const app = createApp(App);

axios.defaults.withCredentials = true;
axios.defaults.baseURL = 'http://localhost:5000/';  // the FastAPI backend

app.use(router);
app.use(store); // New
app.mount("#app");

Components, Views, and Routes

Next, we'll start adding the components and views.

Components

services/frontend/src/components/NavBar.vue:

<template>
  <header>
    <nav class="navbar navbar-expand-md navbar-dark bg-dark">
      <div class="container">
        <a class="navbar-brand" href="/">FastAPI + Vue</a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarCollapse" aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
          <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarCollapse">
          <ul v-if="isLoggedIn" class="navbar-nav me-auto mb-2 mb-md-0">
            <li class="nav-item">
              <router-link class="nav-link" to="/">Home</router-link>
            </li>
            <li class="nav-item">
              <router-link class="nav-link" to="/dashboard">Dashboard</router-link>
            </li>
            <li class="nav-item">
              <router-link class="nav-link" to="/profile">My Profile</router-link>
            </li>
            <li class="nav-item">
              <a class="nav-link" @click="logout">Log Out</a>
            </li>
          </ul>
          <ul v-else class="navbar-nav me-auto mb-2 mb-md-0">
            <li class="nav-item">
              <router-link class="nav-link" to="/">Home</router-link>
            </li>
            <li class="nav-item">
              <router-link class="nav-link" to="/register">Register</router-link>
            </li>
            <li class="nav-item">
              <router-link class="nav-link" to="/login">Log In</router-link>
            </li>
          </ul>
        </div>
      </div>
    </nav>
  </header>
</template>

<script>
import { defineComponent } from 'vue';

export default defineComponent({
  name: 'NavBar',
  computed: {
    isLoggedIn: function() {
      return this.$store.getters.isAuthenticated;
    }
  },
  methods: {
    async logout () {
      await this.$store.dispatch('logOut');
      this.$router.push('/login');
    }
  },
});
</script>

<style scoped>
a {
  cursor: pointer;
}
</style>

The NavBar is used for navigating to other pages in the application. The isLoggedIn property is used to check if a user is logged in from the store. If they are logged in, the dashboard and profile is accessible to them, including the logout link.

The logout function dispatches the logOut action and redirects the user to the /login route.

App

Next, let's add the NavBar component to the main App component.

services/frontend/src/App.vue:

<template>
  <div id="app">
    <NavBar />
    <div class="main container">
      <router-view/>
    </div>
  </div>
</template>

<script>
// @ is an alias to /src
import NavBar from '@/components/NavBar.vue'
export default {
  components: {
    NavBar
  }
}
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  color: #2c3e50;
}
.main {
  padding-top: 5em;
}
</style>

You should now be able to see the new nav bar at http://localhost:8080/.

Views

Home

services/frontend/src/views/HomeView.vue:

<template>
  <section>
    <p>This site is built with FastAPI and Vue.</p>

    <div v-if="isLoggedIn" id="logout">
      <p id="logout">Click <a href="/dashboard">here</a> to view all notes.</p>
    </div>
    <p v-else>
      <span><a href="/register">Register</a></span>
      <span> or </span>
      <span><a href="/login">Log In</a></span>
    </p>
  </section>
</template>
<script>

import { defineComponent } from 'vue';

export default defineComponent({
  name: 'HomeView',
  computed : {
    isLoggedIn: function() {
      return this.$store.getters.isAuthenticated;
    }
  },
});
</script>

Here, the end user is displayed either a link to all notes or links to sign up/in based on the value of the isLoggedIn property.

Next, wire up the view to our routes in services/frontend/src/router/index.js:

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue';


const routes = [
  {
    path: '/',
    name: "Home",
    component: HomeView,
  }
]

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})

export default router

Navigate to http://localhost:8080/. You should see:

home

Register

services/frontend/src/views/RegisterView.vue:

<template>
  <section>
    <form @submit.prevent="submit">
      <div class="mb-3">
        <label for="username" class="form-label">Username:</label>
        <input type="text" name="username" v-model="user.username" class="form-control" />
      </div>
      <div class="mb-3">
        <label for="full_name" class="form-label">Full Name:</label>
        <input type="text" name="full_name" v-model="user.full_name" class="form-control" />
      </div>
      <div class="mb-3">
        <label for="password" class="form-label">Password:</label>
        <input type="password" name="password" v-model="user.password" class="form-control" />
      </div>
      <button type="submit" class="btn btn-primary">Submit</button>
    </form>
  </section>
</template>

<script>
import { defineComponent } from 'vue';
import { mapActions } from 'vuex';

export default defineComponent({
  name: 'Register',
  data() {
    return {
      user: {
        username: '',
        full_name: '',
        password: '',
      },
    };
  },
  methods: {
    ...mapActions(['register']),
    async submit() {
      try {
        await this.register(this.user);
        this.$router.push('/dashboard');
      } catch (error) {
        throw 'Username already exists. Please try again.';
      }
    },
  },
});
</script>

The form takes in the username, full name, and password, all of which are properties on the user object. The Register action is mapped (imported) into the component via mapActions. this.Register is then called and passed the user object. If the result is successful, the user is then redirected to the /dashboard.

Update the router:

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue';
import RegisterView from '@/views/RegisterView.vue';


const routes = [
  {
    path: '/',
    name: "Home",
    component: HomeView,
  },
  {
    path: '/register',
    name: 'Register',
    component: RegisterView,
  },
]

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})

export default router

Test http://localhost:8080/register, ensuring that you can register a new user.

register

Login

services/frontend/src/views/LoginVue.vue:

<template>
  <section>
    <form @submit.prevent="submit">
      <div class="mb-3">
        <label for="username" class="form-label">Username:</label>
        <input type="text" name="username" v-model="form.username" class="form-control" />
      </div>
      <div class="mb-3">
        <label for="password" class="form-label">Password:</label>
        <input type="password" name="password" v-model="form.password" class="form-control" />
      </div>
      <button type="submit" class="btn btn-primary">Submit</button>
    </form>
  </section>
</template>

<script>
import { defineComponent } from 'vue';
import { mapActions } from 'vuex';

export default defineComponent({
  name: 'Login',
  data() {
    return {
      form: {
        username: '',
        password:'',
      }
    };
  },
  methods: {
    ...mapActions(['logIn']),
    async submit() {
      const User = new FormData();
      User.append('username', this.form.username);
      User.append('password', this.form.password);
      await this.logIn(User);
      this.$router.push('/dashboard');
    }
  }
});
</script>

On submission, the logIn action is called. On success, the user is redirected to /dashboard.

Update the router:

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue';
import RegisterView from '@/views/RegisterView.vue';
import LoginView from '@/views/LoginView.vue';


const routes = [
  {
    path: '/',
    name: "Home",
    component: HomeView,
  },
  {
    path: '/register',
    name: 'Register',
    component: RegisterView,
  },
  {
    path: '/login',
    name: 'Login',
    component: LoginView,
  },
]

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})

export default router

Test http://localhost:8080/login, ensuring that you can log a registered user in.

Dashboard

services/frontend/src/views/DashboardView.vue:

<template>
  <div>
    <section>
      <h1>Add new note</h1>
      <hr/><br/>

      <form @submit.prevent="submit">
        <div class="mb-3">
          <label for="title" class="form-label">Title:</label>
          <input type="text" name="title" v-model="form.title" class="form-control" />
        </div>
        <div class="mb-3">
          <label for="content" class="form-label">Content:</label>
          <textarea
            name="content"
            v-model="form.content"
            class="form-control"
          ></textarea>
        </div>
        <button type="submit" class="btn btn-primary">Submit</button>
      </form>
    </section>

    <br/><br/>

    <section>
      <h1>Notes</h1>
      <hr/><br/>

      <div v-if="notes.length">
        <div v-for="note in notes" :key="note.id" class="notes">
          <div class="card" style="width: 18rem;">
            <div class="card-body">
              <ul>
                <li><strong>Note Title:</strong> {{ note.title }}</li>
                <li><strong>Author:</strong> {{ note.author.username }}</li>
                <li><router-link :to="{name: 'Note', params:{id: note.id}}">View</router-link></li>
              </ul>
            </div>
          </div>
          <br/>
        </div>
      </div>

      <div v-else>
        <p>Nothing to see. Check back later.</p>
      </div>
    </section>
  </div>
</template>

<script>
import { defineComponent } from 'vue';
import { mapGetters, mapActions } from 'vuex';

export default defineComponent({
  name: 'Dashboard',
  data() {
    return {
      form: {
        title: '',
        content: '',
      },
    };
  },
  created: function() {
    return this.$store.dispatch('getNotes');
  },
  computed: {
    ...mapGetters({ notes: 'stateNotes'}),
  },
  methods: {
    ...mapActions(['createNote']),
    async submit() {
      await this.createNote(this.form);
    },
  },
});
</script>

The dashboard displays all notes from the API and also allows users to create new notes. Take note of:

<router-link :to="{name: 'Note', params:{id: note.id}}">View</router-link>

We'll configure the route and view here shortly, but the key thing to take away is that the route takes in the note ID and sends the user to the corresponding route -- e.g., note/1, note/2, note/10, note/101, and so forth.

The created function is called during the creation of the component, which hooks into the component lifecycle. In it, we called the mapped getNotes action.

Router:

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue';
import RegisterView from '@/views/RegisterView.vue';
import LoginView from '@/views/LoginView.vue';
import DashboardView from '@/views/DashboardView.vue';


const routes = [
  {
    path: '/',
    name: "Home",
    component: HomeView,
  },
  {
    path: '/register',
    name: 'Register',
    component: RegisterView,
  },
  {
    path: '/login',
    name: 'Login',
    component: LoginView,
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: DashboardView,
    meta: { requiresAuth: true },
  },
]

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})

export default router

Ensure that after you register or log in, you are redirected to the dashboard and that it's now displayed correctly:

dashboard

Profile

services/frontend/src/views/ProfileView.vue:

<template>
  <section>
    <h1>Your Profile</h1>
    <hr/><br/>
    <div>
      <p><strong>Full Name:</strong> <span>{{ user.full_name }}</span></p>
      <p><strong>Username:</strong> <span>{{ user.username }}</span></p>
      <p><button @click="deleteAccount()" class="btn btn-primary">Delete Account</button></p>
    </div>
  </section>
</template>

<script>
import { defineComponent } from 'vue';
import { mapGetters, mapActions } from 'vuex';

export default defineComponent({
  name: 'Profile',
  created: function() {
    return this.$store.dispatch('viewMe');
  },
  computed: {
    ...mapGetters({user: 'stateUser' }),
  },
  methods: {
    ...mapActions(['deleteUser']),
    async deleteAccount() {
      try {
        await this.deleteUser(this.user.id);
        await this.$store.dispatch('logOut');
        this.$router.push('/');
      } catch (error) {
        console.error(error);
      }
    }
  },
});
</script>

The "Delete Account" button calls deleteUser, which sends the user.id to the deleteUser action, logs the user out, and then redirects the user back to the home page.

Router:

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue';
import RegisterView from '@/views/RegisterView.vue';
import LoginView from '@/views/LoginView.vue';
import DashboardView from '@/views/DashboardView.vue';
import ProfileView from '@/views/ProfileView.vue';


const routes = [
  {
    path: '/',
    name: "Home",
    component: HomeView,
  },
  {
    path: '/register',
    name: 'Register',
    component: RegisterView,
  },
  {
    path: '/login',
    name: 'Login',
    component: LoginView,
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: DashboardView,
    meta: { requiresAuth: true },
  },
  {
    path: '/profile',
    name: 'Profile',
    component: ProfileView,
    meta: { requiresAuth: true },
  },
]

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})

export default router

Ensure that you can view your profile at http://localhost:8080/profile. Test out the delete functionality as well.

profile

Note

services/frontend/src/views/NoteView.vue:

<template>
  <div v-if="note">
    <p><strong>Title:</strong> {{ note.title }}</p>
    <p><strong>Content:</strong> {{ note.content }}</p>
    <p><strong>Author:</strong> {{ note.author.username }}</p>

    <div v-if="user.id === note.author.id">
      <p><router-link :to="{name: 'EditNote', params:{id: note.id}}" class="btn btn-primary">Edit</router-link></p>
      <p><button @click="removeNote()" class="btn btn-secondary">Delete</button></p>
    </div>
  </div>
</template>


<script>
import { defineComponent } from 'vue';
import { mapGetters, mapActions } from 'vuex';

export default defineComponent({
  name: 'Note',
  props: ['id'],
  async created() {
    try {
      await this.viewNote(this.id);
    } catch (error) {
      console.error(error);
      this.$router.push('/dashboard');
    }
  },
  computed: {
    ...mapGetters({ note: 'stateNote', user: 'stateUser'}),
  },
  methods: {
    ...mapActions(['viewNote', 'deleteNote']),
    async removeNote() {
      try {
        await this.deleteNote(this.id);
        this.$router.push('/dashboard');
      } catch (error) {
        console.error(error);
      }
    }
  },
});
</script>

This view loads the note details of any note ID passed to it from it's route as a prop.

In the created lifecycle hook, we passed the id from the props to the viewNote action from the store. stateUser and stateNote are mapped into the component, via mapGetters, as user and note, respectively. The "Delete" button triggers the deleteNote method, which, in turn, calls the deleteNote action and redirects the user back to the /dashboard route.

We used an if statement to display the "Edit" and "Delete" buttons only if the note.author is the same as the logged in user.

Router:

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue';
import RegisterView from '@/views/RegisterView.vue';
import LoginView from '@/views/LoginView.vue';
import DashboardView from '@/views/DashboardView.vue';
import ProfileView from '@/views/ProfileView.vue';
import NoteView from '@/views/NoteView.vue';


const routes = [
  {
    path: '/',
    name: "Home",
    component: HomeView,
  },
  {
    path: '/register',
    name: 'Register',
    component: RegisterView,
  },
  {
    path: '/login',
    name: 'Login',
    component: LoginView,
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: DashboardView,
    meta: { requiresAuth: true },
  },
  {
    path: '/profile',
    name: 'Profile',
    component: ProfileView,
    meta: { requiresAuth: true },
  },
  {
    path: '/note/:id',
    name: 'Note',
    component: NoteView,
    meta: { requiresAuth: true },
    props: true,
  },
]

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})

export default router

Since, this route is dyanmic, we set props to true so that the note ID is passed to the view as a prop from the URL.

EditNote

services/frontend/src/views/EditNoteView.vue:

<template>
  <section>
    <h1>Edit note</h1>
    <hr/><br/>

    <form @submit.prevent="submit">
      <div class="mb-3">
        <label for="title" class="form-label">Title:</label>
        <input type="text" name="title" v-model="form.title" class="form-control" />
      </div>
      <div class="mb-3">
        <label for="content" class="form-label">Content:</label>
        <textarea
          name="content"
          v-model="form.content"
          class="form-control"
        ></textarea>
      </div>
      <button type="submit" class="btn btn-primary">Submit</button>
    </form>
  </section>
</template>

<script>
import { defineComponent } from 'vue';
import { mapGetters, mapActions } from 'vuex';

export default defineComponent({
  name: 'EditNote',
  props: ['id'],
  data() {
    return {
      form: {
        title: '',
        content: '',
      },
    };
  },
  created: function() {
    this.GetNote();
  },
  computed: {
    ...mapGetters({ note: 'stateNote' }),
  },
  methods: {
    ...mapActions(['updateNote', 'viewNote']),
    async submit() {
    try {
      let note = {
        id: this.id,
        form: this.form,
      };
      await this.updateNote(note);
      this.$router.push({name: 'Note', params:{id: this.note.id}});
    } catch (error) {
      console.log(error);
    }
    },
    async GetNote() {
      try {
        await this.viewNote(this.id);
        this.form.title = this.note.title;
        this.form.content = this.note.content;
      } catch (error) {
        console.error(error);
        this.$router.push('/dashboard');
      }
    }
  },
});
</script>

This view displays a pre-loaded form with the note title and content for the author to edit and update. Similar to the Note view, the id of the note is passed from the router object to the page as a prop.

The getNote method is used to load the form with the note info. It passes the id to the viewNote action and uses the note getter values to fill the form. While the component is being created, the getNote function is called.

Router:

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue';
import RegisterView from '@/views/RegisterView.vue';
import LoginView from '@/views/LoginView.vue';
import DashboardView from '@/views/DashboardView.vue';
import ProfileView from '@/views/ProfileView.vue';
import NoteView from '@/views/NoteView.vue';
import EditNoteView from '@/views/EditNoteView.vue';


const routes = [
  {
    path: '/',
    name: "Home",
    component: HomeView,
  },
  {
    path: '/register',
    name: 'Register',
    component: RegisterView,
  },
  {
    path: '/login',
    name: 'Login',
    component: LoginView,
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: DashboardView,
    meta: { requiresAuth: true },
  },
  {
    path: '/profile',
    name: 'Profile',
    component: ProfileView,
    meta: { requiresAuth: true },
  },
  {
    path: '/note/:id',
    name: 'Note',
    component: NoteView,
    meta: { requiresAuth: true },
    props: true,
  },
  {
    path: '/editnote/:id',
    name: 'EditNote',
    component: EditNoteView,
    meta: { requiresAuth: true },
    props: true,
  },
]

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})

export default router

From the dashboard, add a new note:

add_note

Then, click the link to view a new note. Make sure the "Edit" and "Delete" buttons are displayed only if the logged in user is the note creator:

note

Also, make sure you can edit and delete a note as well before moving on.

Unauthorized Users and Expired Tokens

Unauthorized Users

Did you notice that some routes have meta: {requiresAuth: true}, attached to them? These routes shouldn't be accessible to unauthenticated users.

For example, what happens if you navigate to http://localhost:8080/profile when you're not authenticated? You should be able to view the page but no data loads, right? Let's change that so the user is redirected to the /login route instead.

So, to prevent unauthorized access, let's add a Navigation Guard to services/frontend/src/router/index.js:

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue';
import RegisterView from '@/views/RegisterView.vue';
import LoginView from '@/views/LoginView.vue';
import DashboardView from '@/views/DashboardView.vue';
import ProfileView from '@/views/ProfileView.vue';
import NoteView from '@/views/NoteView.vue';
import EditNoteView from '@/views/EditNoteView.vue';
import store from '@/store'; // NEW


const routes = [
  ...
]

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})

// NEW
router.beforeEach((to, _, next) => {
  if (to.matched.some(record => record.meta.requiresAuth)) {
    if (store.getters.isAuthenticated) {
      next();
      return;
    }
    next('/login');
  } else {
    next();
  }
});

export default router

Log out. Then, test http://localhost:8080/profile again. You should be redirected back to the /login route.

Expired Tokens

Remember that the token expires after thirty minutes:

ACCESS_TOKEN_EXPIRE_MINUTES = 30

When this happens, the user should be logged out and redirected to the log in page. To handle this, let's add an Axios Interceptor to services/frontend/src/main.js:

import 'bootstrap/dist/css/bootstrap.css';
import { createApp } from "vue";
import axios from 'axios';

import App from './App.vue';
import router from './router';
import store from './store';

const app = createApp(App);

axios.defaults.withCredentials = true;
axios.defaults.baseURL = 'http://localhost:5000/';  // the FastAPI backend

// NEW
axios.interceptors.response.use(undefined, function (error) {
  if (error) {
    const originalRequest = error.config;
    if (error.response.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;
      store.dispatch('logOut');
      return router.push('/login')
    }
  }
});

app.use(router);
app.use(store);
app.mount("#app");

If you'd like to test, change ACCESS_TOKEN_EXPIRE_MINUTES = 30 to something like ACCESS_TOKEN_EXPIRE_MINUTES = 1. Keep in mind that the cookie itself still lasts for 30 minutes. It's the token that expires.

Conclusion

This tutorial covered the basics of setting up a CRUD app with Vue and FastAPI. Along with the apps, you also used Docker to simplify development and added authentication.

Check your understanding by reviewing the objectives from the beginning and going through each of the challenges below.

You can find the source code in the fastapi-vue repo on GitHub. Cheers!

--

Looking for more?

  1. Test all the things. Stop hacking. Start ensuring that your applications work as expected. Need help? Check out the following resources:
  2. Add alerts to display proper success and error messages to end users. Check out the Alert Component section from Developing a Single Page App with Flask and Vue.js for more on setting up alerts with Bootstrap and Vue.
  3. Add a new endpoint to the backend that gets called when a user logs out that updates the cookie like so.
Featured Course

Test-Driven Development with FastAPI and Docker

In this course, you'll learn how to build, test, and deploy a text summarization service with Python, FastAPI, and Docker. The service itself will be exposed via a RESTful API and deployed to Heroku with Docker.

Featured Course

Test-Driven Development with FastAPI and Docker

In this course, you'll learn how to build, test, and deploy a text summarization service with Python, FastAPI, and Docker. The service itself will be exposed via a RESTful API and deployed to Heroku with Docker.