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:
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:
- Explain what FastAPI is
- Explain what Vue is and how it compares to other UI libraries and frontend frameworks like React and Angular
- Develop a RESTful API with FastAPI
- Scaffold a Vue project using the Vue CLI
- Create and render Vue components in the browser
- Create a Single Page Application (SPA) with Vue components
- Connect a Vue application to a FastAPI back-end
- Style Vue Components with Bootstrap
- Use the Vue Router to create routes and render components
- 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:
- Heavily inspired by Flask, it has a lightweight microframework feel with support for Flask-like route decorators.
- It takes advantage of Python type hints for parameter declaration which enables data validation (via pydantic) and OpenAPI/Swagger documentation.
- Built on top of Starlette, it supports the development of asynchronous APIs.
- 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.
- Because it's based on and fully compatible with OpenAPI and JSON Schema, it supports a number of powerful tools, like Swagger UI.
- It has amazing documentation.
First time with FastAPI? Check out the following resources:
- Developing and Testing an Asynchronous API with FastAPI and Pytest
- 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:
- Vue: Comparison with Other Frameworks
- Learn Vue by Building and Deploying a CRUD App
- React vs Angular vs Vue.js
First time with Vue?
- Take a moment to read through the Introduction from the official Vue guide.
- 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:
Core functionality:
- Authenticated users will be able to view, add, update, and delete notes
- 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:
- Defined the database connection via the
DATABASE_URL
environment variable - Registered our models,
src.database.models
(users and notes) andaerich.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:
UserInSchema
is for creating new users.UserOutSchema
is for retrieving user info to be used outside our application, for returning to end users.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:
NoteInSchema
is for creating new notes.NoteOutSchema
is for retrieving notes.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:
create_user
takes in a user, encryptsuser.password
, and then adds the user to the database.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:
TokenData
is for ensuring the username from the token is a string.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:
OAuth2PasswordBearerCookie
is a class that inherits from theOAuth2
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.- The
create_access_token
function takes in the user's username, encodes it with the expiring time, and generates a token from it. 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 a401_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?
get_current_user
is attached toread_users_me
anddelete_user
in order to protect the routes. Unless the user is logged in ascurrent_user
, they won't be able to access them./register
leverages thecrud.create_user
helper to create a new user and add it to the database./login
takes in a user via form data fromOAuth2PasswordRequestForm
containing the username and password. It then calls thevalidate_user
function with the user or throws an exception ifNone
. An access token is generated from thecreate_access_token
function and then attached to the response header as a cookie./users/whoami
takes inget_current_user
and sends back the results as a response./user/{user_id}
is a dynamic route that takes in theuser_id
and sends it to thecrud.delete_user
helper with the results fromcurrent_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:
- The name of the cookie is
Authorization
with a value ofBearer {token}
, withtoken
being the actual token. It expires after 1800 seconds (30 minutes). - 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. - With samesite set to
Lax
, the browser only sends cookies on some HTTP requests. This helps prevent Cross Site Request Forgery (CSRF) attacks. - Finally,
secure
is set toFalse
since we'll be testing locally, without HTTPS. Make sure to set this toTrue
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:
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:
state
- bothnote
andnotes
default tonull
. They'll be updated to an object and an array of objects, respectively.getters
- retrieves the values ofstate.note
andstate.notes
.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.mutations
- both make changes to the state, which updatestate.note
andstate.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:
isAuthenticated
- returnstrue
ifstate.user
is notnull
andfalse
otherwise.stateUser
- returns the value ofstate.user
.register
- sends a POST request to the/register
endpoint we created in the backend, creates a FormData instance, and dispatches it to thelogIn
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
NavBar
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:
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.
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:
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.
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:
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:
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?
- Test all the things. Stop hacking. Start ensuring that your applications work as expected. Need help? Check out the following resources:
- 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.
- Add a new endpoint to the backend that gets called when a user logs out that updates the cookie like so.