├── .gitignore
├── services
├── frontend
│ ├── babel.config.js
│ ├── public
│ │ ├── favicon.ico
│ │ └── index.html
│ ├── src
│ │ ├── assets
│ │ │ └── logo.png
│ │ ├── views
│ │ │ ├── About.vue
│ │ │ ├── Home.vue
│ │ │ ├── Profile.vue
│ │ │ ├── Login.vue
│ │ │ ├── Note.vue
│ │ │ ├── Register.vue
│ │ │ ├── EditNote.vue
│ │ │ └── Dashboard.vue
│ │ ├── store
│ │ │ ├── index.js
│ │ │ └── modules
│ │ │ │ ├── notes.js
│ │ │ │ └── users.js
│ │ ├── components
│ │ │ ├── HelloWorld.vue
│ │ │ └── NavBar.vue
│ │ ├── App.vue
│ │ ├── main.js
│ │ └── router
│ │ │ └── index.js
│ ├── Dockerfile
│ ├── .gitignore
│ ├── README.md
│ └── package.json
└── backend
│ ├── aerich.ini
│ ├── requirements.txt
│ ├── src
│ ├── schemas
│ │ ├── token.py
│ │ ├── users.py
│ │ └── notes.py
│ ├── database
│ │ ├── config.py
│ │ ├── register.py
│ │ └── models.py
│ ├── main.py
│ ├── auth
│ │ ├── users.py
│ │ └── jwthandler.py
│ ├── crud
│ │ ├── users.py
│ │ └── notes.py
│ └── routes
│ │ ├── notes.py
│ │ └── users.py
│ ├── Dockerfile
│ └── migrations
│ └── models
│ └── 3_20210814144818_None.sql
├── README.md
├── docker-compose.yml
└── LICENSE
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules/
3 | /dist/
4 | env/
5 | __pycache__
6 |
--------------------------------------------------------------------------------
/services/frontend/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | '@vue/cli-plugin-babel/preset'
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/services/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/altryne/fastapi-vue/master/services/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/services/frontend/src/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/altryne/fastapi-vue/master/services/frontend/src/assets/logo.png
--------------------------------------------------------------------------------
/services/backend/aerich.ini:
--------------------------------------------------------------------------------
1 | [aerich]
2 | tortoise_orm = src.database.config.TORTOISE_ORM
3 | location = ./migrations
4 | src_folder = ./.
5 |
6 |
--------------------------------------------------------------------------------
/services/frontend/src/views/About.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
This is an about page
4 |
5 |
6 |
--------------------------------------------------------------------------------
/services/backend/requirements.txt:
--------------------------------------------------------------------------------
1 | aerich==0.5.5
2 | asyncpg==0.23.0
3 | bcrypt==3.2.0
4 | fastapi==0.68.0
5 | passlib==1.7.4
6 | python-jose==3.3.0
7 | python-multipart==0.0.5
8 | tortoise-orm==0.17.6
9 | uvicorn==0.14.0
10 |
--------------------------------------------------------------------------------
/services/backend/src/schemas/token.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from pydantic import BaseModel
4 |
5 |
6 | class TokenData(BaseModel):
7 | username: Optional[str] = None
8 |
9 |
10 | class Status(BaseModel):
11 | message: str
12 |
--------------------------------------------------------------------------------
/services/frontend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:lts-alpine
2 |
3 | WORKDIR /app
4 |
5 | ENV PATH /app/node_modules/.bin:$PATH
6 |
7 | RUN npm install @vue/cli@4.5.13 -g
8 |
9 | COPY package.json .
10 | COPY package-lock.json .
11 | RUN npm install
12 |
13 | CMD ["npm", "run", "serve"]
14 |
--------------------------------------------------------------------------------
/services/backend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.9-buster
2 |
3 | RUN mkdir app
4 | WORKDIR /app
5 |
6 | ENV PATH="${PATH}:/root/.local/bin"
7 | ENV PYTHONPATH=.
8 |
9 | COPY requirements.txt .
10 | RUN pip install --upgrade pip
11 | RUN pip install -r requirements.txt
12 |
13 | # for migrations
14 | COPY migrations .
15 | COPY aerich.ini .
16 |
17 | COPY src/ .
18 |
--------------------------------------------------------------------------------
/services/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /dist
4 |
5 |
6 | # local env files
7 | .env.local
8 | .env.*.local
9 |
10 | # Log files
11 | npm-debug.log*
12 | yarn-debug.log*
13 | yarn-error.log*
14 | pnpm-debug.log*
15 |
16 | # Editor directories and files
17 | .idea
18 | .vscode
19 | *.suo
20 | *.ntvs*
21 | *.njsproj
22 | *.sln
23 | *.sw?
24 |
--------------------------------------------------------------------------------
/services/backend/src/database/config.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 |
4 | TORTOISE_ORM = {
5 | "connections": {"default": os.environ.get("DATABASE_URL")},
6 | "apps": {
7 | "models": {
8 | "models": [
9 | "src.database.models", "aerich.models"
10 | ],
11 | "default_connection": "default"
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/services/frontend/src/store/index.js:
--------------------------------------------------------------------------------
1 | import createPersistedState from "vuex-persistedstate";
2 | import Vue from 'vue';
3 | import Vuex from 'vuex';
4 |
5 | import notes from './modules/notes';
6 | import users from './modules/users';
7 |
8 |
9 | Vue.use(Vuex);
10 |
11 | export default new Vuex.Store({
12 | modules: {
13 | notes,
14 | users,
15 | },
16 | plugins: [createPersistedState()]
17 | });
18 |
--------------------------------------------------------------------------------
/services/frontend/README.md:
--------------------------------------------------------------------------------
1 | # frontend
2 |
3 | ## Project setup
4 | ```
5 | npm install
6 | ```
7 |
8 | ### Compiles and hot-reloads for development
9 | ```
10 | npm run serve
11 | ```
12 |
13 | ### Compiles and minifies for production
14 | ```
15 | npm run build
16 | ```
17 |
18 | ### Lints and fixes files
19 | ```
20 | npm run lint
21 | ```
22 |
23 | ### Customize configuration
24 | See [Configuration Reference](https://cli.vuejs.org/config/).
25 |
--------------------------------------------------------------------------------
/services/backend/src/schemas/users.py:
--------------------------------------------------------------------------------
1 | from tortoise.contrib.pydantic import pydantic_model_creator
2 |
3 | from src.database.models import Users
4 |
5 |
6 | UserInSchema = pydantic_model_creator(
7 | Users, name="UserIn", exclude_readonly=True
8 | )
9 | UserOutSchema = pydantic_model_creator(
10 | Users, name="UserOut", exclude=["password", "created_at", "modified_at"]
11 | )
12 | UserDatabaseSchema = pydantic_model_creator(
13 | Users, name="User", exclude=["created_at", "modified_at"]
14 | )
15 |
--------------------------------------------------------------------------------
/services/backend/src/database/register.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from tortoise import Tortoise
4 |
5 |
6 | def register_tortoise(
7 | app,
8 | config: Optional[dict] = None,
9 | generate_schemas: bool = False,
10 | ) -> None:
11 | @app.on_event("startup")
12 | async def init_orm():
13 | await Tortoise.init(config=config)
14 | if generate_schemas:
15 | await Tortoise.generate_schemas()
16 |
17 | @app.on_event("shutdown")
18 | async def close_orm():
19 | await Tortoise.close_connections()
20 |
--------------------------------------------------------------------------------
/services/frontend/src/components/HelloWorld.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
33 |
--------------------------------------------------------------------------------
/services/frontend/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
19 |
20 |
31 |
--------------------------------------------------------------------------------
/services/backend/src/schemas/notes.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from pydantic import BaseModel
4 | from tortoise.contrib.pydantic import pydantic_model_creator
5 |
6 | from src.database.models import Notes
7 |
8 |
9 | NoteInSchema = pydantic_model_creator(
10 | Notes, name="NoteIn", exclude=["author_id"], exclude_readonly=True)
11 | NoteOutSchema = pydantic_model_creator(
12 | Notes, name="Note", exclude =[
13 | "modified_at", "author.password", "author.created_at", "author.modified_at"
14 | ]
15 | )
16 |
17 |
18 | class UpdateNote(BaseModel):
19 | title: Optional[str]
20 | content: Optional[str]
21 |
--------------------------------------------------------------------------------
/services/frontend/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | FastAPI + Vue
9 |
10 |
11 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Developing a Single Page App with FastAPI and Vue.js
2 |
3 | ### Want to learn how to build this?
4 |
5 | Check out the [post](https://testdriven.io/blog/developing-a-single-page-app-with-fastapi-and-vuejs).
6 |
7 | ## Want to use this project?
8 |
9 | Build the images and spin up the containers:
10 |
11 | ```sh
12 | $ docker-compose up -d --build
13 | ```
14 |
15 | Apply the migrations:
16 |
17 | ```sh
18 | $ docker-compose exec backend aerich upgrade
19 | ```
20 |
21 | Ensure [http://localhost:5000](http://localhost:5000), [http://localhost:5000/docs](http://localhost:5000/docs), and [http://localhost:8080](http://localhost:8080) work as expected.
22 |
--------------------------------------------------------------------------------
/services/frontend/src/views/Home.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | This site is built with FastAPI and Vue.
4 |
5 |
6 |
Click here to view all notes.
7 |
8 |
9 | Register
10 | or
11 | Log In
12 |
13 |
14 |
15 |
26 |
--------------------------------------------------------------------------------
/services/frontend/src/main.js:
--------------------------------------------------------------------------------
1 | import 'bootstrap/dist/css/bootstrap.css';
2 | import axios from 'axios';
3 | import Vue from 'vue';
4 |
5 | import App from './App.vue';
6 | import router from './router';
7 | import store from './store';
8 |
9 |
10 | axios.defaults.withCredentials = true;
11 | axios.defaults.baseURL = 'http://localhost:5000/'; // the FastAPI backend
12 |
13 | Vue.config.productionTip = false;
14 |
15 | axios.interceptors.response.use(undefined, function (error) {
16 | if (error) {
17 | const originalRequest = error.config;
18 | if (error.response.status === 401 && !originalRequest._retry) {
19 | originalRequest._retry = true;
20 | store.dispatch('logOut');
21 | return router.push('/login')
22 | }
23 | }
24 | });
25 |
26 | new Vue({
27 | router,
28 | store,
29 | render: h => h(App)
30 | }).$mount('#app');
31 |
--------------------------------------------------------------------------------
/services/backend/src/database/models.py:
--------------------------------------------------------------------------------
1 | from tortoise import fields, models
2 |
3 |
4 | class Users(models.Model):
5 | id = fields.IntField(pk=True)
6 | username = fields.CharField(max_length=20, unique=True)
7 | full_name = fields.CharField(max_length=50, null=True)
8 | password = fields.CharField(max_length=128, null=True)
9 | created_at = fields.DatetimeField(auto_now_add=True)
10 | modified_at = fields.DatetimeField(auto_now=True)
11 |
12 |
13 | class Notes(models.Model):
14 | id = fields.IntField(pk=True)
15 | title = fields.CharField(max_length=225)
16 | content = fields.TextField()
17 | author = fields.ForeignKeyField("models.Users", related_name="note")
18 | created_at = fields.DatetimeField(auto_now_add=True)
19 | modified_at = fields.DatetimeField(auto_now=True)
20 |
21 | def __str__(self):
22 | return f"{self.title}, {self.author_id} on {self.created_at}"
23 |
--------------------------------------------------------------------------------
/services/backend/migrations/models/3_20210814144818_None.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | CREATE TABLE IF NOT EXISTS "users" (
3 | "id" SERIAL NOT NULL PRIMARY KEY,
4 | "username" VARCHAR(20) NOT NULL UNIQUE,
5 | "full_name" VARCHAR(50),
6 | "password" VARCHAR(128),
7 | "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
8 | "modified_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
9 | );
10 | CREATE TABLE IF NOT EXISTS "notes" (
11 | "id" SERIAL NOT NULL PRIMARY KEY,
12 | "title" VARCHAR(225) NOT NULL,
13 | "content" TEXT NOT NULL,
14 | "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
15 | "modified_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
16 | "author_id" INT NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE
17 | );
18 | CREATE TABLE IF NOT EXISTS "aerich" (
19 | "id" SERIAL NOT NULL PRIMARY KEY,
20 | "version" VARCHAR(255) NOT NULL,
21 | "app" VARCHAR(20) NOT NULL,
22 | "content" JSONB NOT NULL
23 | );
24 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 |
3 | services:
4 |
5 | backend:
6 | build: ./services/backend
7 | ports:
8 | - 5000:5000
9 | environment:
10 | - DATABASE_URL=postgres://hello_fastapi:hello_fastapi@db:5432/hello_fastapi_dev
11 | - SECRET_KEY=09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7
12 | volumes:
13 | - ./services/backend:/app
14 | command: uvicorn src.main:app --reload --host 0.0.0.0 --port 5000
15 | depends_on:
16 | - db
17 |
18 | frontend:
19 | build: ./services/frontend
20 | volumes:
21 | - './services/frontend:/app'
22 | - '/app/node_modules'
23 | ports:
24 | - 8080:8080
25 |
26 | db:
27 | image: postgres:13
28 | expose:
29 | - 5432
30 | environment:
31 | - POSTGRES_USER=hello_fastapi
32 | - POSTGRES_PASSWORD=hello_fastapi
33 | - POSTGRES_DB=hello_fastapi_dev
34 | volumes:
35 | - postgres_data:/var/lib/postgresql/data/
36 |
37 | volumes:
38 | postgres_data:
39 |
--------------------------------------------------------------------------------
/services/frontend/src/views/Profile.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Your Profile
4 |
5 |
6 |
Full Name: {{ user.full_name }}
7 |
Username: {{ user.username }}
8 |
9 |
10 |
11 |
12 |
13 |
37 |
--------------------------------------------------------------------------------
/services/backend/src/main.py:
--------------------------------------------------------------------------------
1 | from fastapi import FastAPI
2 | from fastapi.middleware.cors import CORSMiddleware
3 | from tortoise import Tortoise
4 |
5 | from src.database.register import register_tortoise
6 | from src.database.config import TORTOISE_ORM
7 |
8 |
9 | # enable schemas to read relationship between models
10 | Tortoise.init_models(["src.database.models"], "models")
11 |
12 | """
13 | import 'from src.routes import users, notes' must be after 'Tortoise.init_models'
14 | why?
15 | https://stackoverflow.com/questions/65531387/tortoise-orm-for-python-no-returns-relations-of-entities-pyndantic-fastapi
16 | """
17 | from src.routes import users, notes
18 |
19 | app = FastAPI()
20 |
21 | app.add_middleware(
22 | CORSMiddleware,
23 | allow_origins=["http://localhost:8080"],
24 | allow_credentials=True,
25 | allow_methods=["*"],
26 | allow_headers=["*"],
27 | )
28 | app.include_router(users.router)
29 | app.include_router(notes.router)
30 |
31 | register_tortoise(app, config=TORTOISE_ORM, generate_schemas=False)
32 |
33 |
34 | @app.get("/")
35 | def home():
36 | return "Hello, World!"
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Michael Herman
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/services/frontend/src/store/modules/notes.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | const state = {
4 | notes: null,
5 | note: null
6 | };
7 |
8 | const getters = {
9 | stateNotes: state => state.notes,
10 | stateNote: state => state.note,
11 | };
12 |
13 | const actions = {
14 | async createNote({dispatch}, note) {
15 | await axios.post('notes', note);
16 | await dispatch('getNotes');
17 | },
18 | async getNotes({commit}) {
19 | let {data} = await axios.get('notes');
20 | commit('setNotes', data);
21 | },
22 | async viewNote({commit}, id) {
23 | let {data} = await axios.get(`note/${id}`);
24 | commit('setNote', data);
25 | },
26 | // eslint-disable-next-line no-empty-pattern
27 | async updateNote({}, note) {
28 | await axios.patch(`note/${note.id}`, note.form);
29 | },
30 | // eslint-disable-next-line no-empty-pattern
31 | async deleteNote({}, id) {
32 | await axios.delete(`note/${id}`);
33 | }
34 | };
35 |
36 | const mutations = {
37 | setNotes(state, notes){
38 | state.notes = notes;
39 | },
40 | setNote(state, note){
41 | state.note = note;
42 | },
43 | };
44 |
45 | export default {
46 | state,
47 | getters,
48 | actions,
49 | mutations
50 | };
51 |
--------------------------------------------------------------------------------
/services/frontend/src/views/Login.vue:
--------------------------------------------------------------------------------
1 |
2 |
15 |
16 |
17 |
41 |
--------------------------------------------------------------------------------
/services/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "serve": "vue-cli-service serve",
7 | "build": "vue-cli-service build",
8 | "lint": "vue-cli-service lint"
9 | },
10 | "dependencies": {
11 | "axios": "^0.21.1",
12 | "bootstrap": "^5.1.0",
13 | "core-js": "^3.6.5",
14 | "vue": "^2.6.11",
15 | "vue-router": "^3.2.0",
16 | "vuex": "^3.6.2",
17 | "vuex-persistedstate": "^4.0.0"
18 | },
19 | "devDependencies": {
20 | "@vue/cli-plugin-babel": "~4.5.0",
21 | "@vue/cli-plugin-eslint": "~4.5.0",
22 | "@vue/cli-plugin-router": "^4.5.13",
23 | "@vue/cli-service": "~4.5.0",
24 | "babel-eslint": "^10.1.0",
25 | "eslint": "^6.7.2",
26 | "eslint-plugin-vue": "^6.2.2",
27 | "vue-template-compiler": "^2.6.11"
28 | },
29 | "eslintConfig": {
30 | "root": true,
31 | "env": {
32 | "node": true
33 | },
34 | "extends": [
35 | "plugin:vue/essential",
36 | "eslint:recommended"
37 | ],
38 | "parserOptions": {
39 | "parser": "babel-eslint"
40 | },
41 | "rules": {}
42 | },
43 | "browserslist": [
44 | "> 1%",
45 | "last 2 versions",
46 | "not dead"
47 | ]
48 | }
49 |
--------------------------------------------------------------------------------
/services/frontend/src/views/Note.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Title: {{ note.title }}
4 |
Content: {{ note.content }}
5 |
Author: {{ note.author.username }}
6 |
7 |
11 |
12 |
13 |
14 |
15 |
44 |
--------------------------------------------------------------------------------
/services/frontend/src/store/modules/users.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | const state = {
4 | user: null,
5 | };
6 |
7 | const getters = {
8 | isAuthenticated: state => !!state.user,
9 | stateUser: state => state.user,
10 | };
11 |
12 | const actions = {
13 | async register({dispatch}, form) {
14 | await axios.post('register', form);
15 | let UserForm = new FormData();
16 | UserForm.append('username', form.username);
17 | UserForm.append('password', form.password);
18 | await dispatch('logIn', UserForm);
19 | },
20 | async logIn({dispatch}, user) {
21 | await axios.post('login', user);
22 | await dispatch('viewMe');
23 | },
24 | async viewMe({commit}) {
25 | let {data} = await axios.get('users/whoami');
26 | await commit('setUser', data);
27 | },
28 | // eslint-disable-next-line no-empty-pattern
29 | async deleteUser({}, id) {
30 | await axios.delete(`user/${id}`);
31 | },
32 | async logOut({commit}) {
33 | let user = null;
34 | commit('logout', user);
35 | }
36 | };
37 |
38 | const mutations = {
39 | setUser(state, username) {
40 | state.user = username;
41 | },
42 | logout(state, user){
43 | state.user = user;
44 | },
45 | };
46 |
47 | export default {
48 | state,
49 | getters,
50 | actions,
51 | mutations
52 | };
53 |
--------------------------------------------------------------------------------
/services/backend/src/auth/users.py:
--------------------------------------------------------------------------------
1 | from fastapi import HTTPException, Depends, status
2 | from fastapi.security import OAuth2PasswordRequestForm
3 | from passlib.context import CryptContext
4 | from tortoise.exceptions import DoesNotExist
5 |
6 | from src.database.models import Users
7 | from src.schemas.users import UserDatabaseSchema
8 |
9 |
10 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
11 |
12 |
13 | def verify_password(plain_password, hashed_password):
14 | return pwd_context.verify(plain_password, hashed_password)
15 |
16 |
17 | def get_password_hash(password):
18 | return pwd_context.hash(password)
19 |
20 |
21 | async def get_user(username: str):
22 | return await UserDatabaseSchema.from_queryset_single(Users.get(username=username))
23 |
24 |
25 | async def validate_user(user: OAuth2PasswordRequestForm = Depends()):
26 | try:
27 | db_user = await get_user(user.username)
28 | except DoesNotExist:
29 | raise HTTPException(
30 | status_code=status.HTTP_401_UNAUTHORIZED,
31 | detail="Incorrect username or password",
32 | )
33 |
34 | if not verify_password(user.password, db_user.password):
35 | raise HTTPException(
36 | status_code=status.HTTP_401_UNAUTHORIZED,
37 | detail="Incorrect username or password",
38 | )
39 |
40 | return db_user
41 |
--------------------------------------------------------------------------------
/services/backend/src/crud/users.py:
--------------------------------------------------------------------------------
1 | from fastapi import HTTPException
2 | from passlib.context import CryptContext
3 | from tortoise.exceptions import DoesNotExist, IntegrityError
4 |
5 | from src.database.models import Users
6 | from src.schemas.token import Status
7 | from src.schemas.users import UserOutSchema
8 |
9 |
10 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
11 |
12 |
13 | async def create_user(user) -> UserOutSchema:
14 | user.password = pwd_context.encrypt(user.password)
15 |
16 | try:
17 | user_obj = await Users.create(**user.dict(exclude_unset=True))
18 | except IntegrityError:
19 | raise HTTPException(status_code=401, detail=f"Sorry, that username already exists.")
20 |
21 | return await UserOutSchema.from_tortoise_orm(user_obj)
22 |
23 |
24 | async def delete_user(user_id, current_user) -> Status:
25 | try:
26 | db_user = await UserOutSchema.from_queryset_single(Users.get(id=user_id))
27 | except DoesNotExist:
28 | raise HTTPException(status_code=404, detail=f"User {user_id} not found")
29 |
30 | if db_user.id == current_user.id:
31 | deleted_count = await Users.filter(id=user_id).delete()
32 | if not deleted_count:
33 | raise HTTPException(status_code=404, detail=f"User {user_id} not found")
34 | return Status(message=f"Deleted user {user_id}")
35 |
36 | raise HTTPException(status_code=403, detail=f"Not authorized to delete")
37 |
--------------------------------------------------------------------------------
/services/frontend/src/views/Register.vue:
--------------------------------------------------------------------------------
1 |
2 |
19 |
20 |
21 |
47 |
--------------------------------------------------------------------------------
/services/frontend/src/router/index.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 | import VueRouter from 'vue-router';
3 |
4 | import store from '@/store';
5 |
6 | import Dashboard from '@/views/Dashboard';
7 | import EditNote from '@/views/EditNote';
8 | import Home from '@/views/Home.vue';
9 | import Login from '@/views/Login';
10 | import Note from '@/views/Note';
11 | import Profile from '@/views/Profile';
12 | import Register from '@/views/Register';
13 |
14 | Vue.use(VueRouter);
15 |
16 | const routes = [
17 | {
18 | path: '/',
19 | name: "Home",
20 | component: Home,
21 | },
22 | {
23 | path: '/register',
24 | name: 'Register',
25 | component: Register,
26 | },
27 | {
28 | path: '/login',
29 | name: 'Login',
30 | component: Login,
31 | },
32 | {
33 | path: '/dashboard',
34 | name: 'Dashboard',
35 | component: Dashboard,
36 | meta: {requiresAuth: true},
37 | },
38 | {
39 | path: '/profile',
40 | name: 'Profile',
41 | component: Profile,
42 | meta: {requiresAuth: true},
43 | },
44 | {
45 | path: '/note/:id',
46 | name: 'Note',
47 | component: Note,
48 | meta: {requiresAuth: true},
49 | props: true,
50 | },
51 | {
52 | path: '/editnote/:id',
53 | name: 'EditNote',
54 | component: EditNote,
55 | meta: {requiresAuth: true},
56 | props: true,
57 | }
58 | ]
59 |
60 | const router = new VueRouter({
61 | mode: 'history',
62 | base: process.env.BASE_URL,
63 | routes,
64 | });
65 |
66 | router.beforeEach((to, from, next) => {
67 | if (to.matched.some(record => record.meta.requiresAuth)) {
68 | if (store.getters.isAuthenticated) {
69 | next();
70 | return;
71 | }
72 | next('/login');
73 | } else {
74 | next();
75 | }
76 | });
77 |
78 | export default router;
79 |
--------------------------------------------------------------------------------
/services/frontend/src/views/EditNote.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Edit note
4 |
5 |
6 |
21 |
22 |
23 |
24 |
70 |
--------------------------------------------------------------------------------
/services/backend/src/crud/notes.py:
--------------------------------------------------------------------------------
1 | from fastapi import HTTPException
2 | from tortoise.exceptions import DoesNotExist
3 |
4 | from src.database.models import Notes
5 | from src.schemas.notes import NoteOutSchema
6 | from src.schemas.token import Status
7 |
8 |
9 | async def get_notes():
10 | return await NoteOutSchema.from_queryset(Notes.all())
11 |
12 |
13 | async def get_note(note_id) -> NoteOutSchema:
14 | return await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))
15 |
16 |
17 | async def create_note(note, current_user) -> NoteOutSchema:
18 | note_dict = note.dict(exclude_unset=True)
19 | note_dict["author_id"] = current_user.id
20 | note_obj = await Notes.create(**note_dict)
21 | return await NoteOutSchema.from_tortoise_orm(note_obj)
22 |
23 |
24 | async def update_note(note_id, note, current_user) -> NoteOutSchema:
25 | try:
26 | db_note = await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))
27 | except DoesNotExist:
28 | raise HTTPException(status_code=404, detail=f"Note {note_id} not found")
29 |
30 | if db_note.author.id == current_user.id:
31 | await Notes.filter(id=note_id).update(**note.dict(exclude_unset=True))
32 | return await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))
33 |
34 | raise HTTPException(status_code=403, detail=f"Not authorized to update")
35 |
36 |
37 | async def delete_note(note_id, current_user) -> Status:
38 | try:
39 | db_note = await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))
40 | except DoesNotExist:
41 | raise HTTPException(status_code=404, detail=f"Note {note_id} not found")
42 |
43 | if db_note.author.id == current_user.id:
44 | deleted_count = await Notes.filter(id=note_id).delete()
45 | if not deleted_count:
46 | raise HTTPException(status_code=404, detail=f"Note {note_id} not found")
47 | return Status(message=f"Deleted note {note_id}")
48 |
49 | raise HTTPException(status_code=403, detail=f"Not authorized to delete")
50 |
--------------------------------------------------------------------------------
/services/frontend/src/views/Dashboard.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
23 |
24 |
25 |
26 |
27 | Notes
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | - Note Title: {{ note.title }}
36 | - Author: {{ note.author.username }}
37 | - View
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
Nothing to see. Check back later.
47 |
48 |
49 |
50 |
51 |
52 |
78 |
--------------------------------------------------------------------------------
/services/frontend/src/components/NavBar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
38 |
39 |
40 |
41 |
57 |
58 |
63 |
--------------------------------------------------------------------------------
/services/backend/src/routes/notes.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | from fastapi import APIRouter, Depends, HTTPException
4 | from tortoise.contrib.fastapi import HTTPNotFoundError
5 | from tortoise.exceptions import DoesNotExist
6 |
7 | import src.crud.notes as crud
8 | from src.auth.jwthandler import get_current_user
9 | from src.schemas.notes import NoteOutSchema, NoteInSchema, UpdateNote
10 | from src.schemas.token import Status
11 | from src.schemas.users import UserOutSchema
12 |
13 |
14 | router = APIRouter()
15 |
16 |
17 | @router.get(
18 | "/notes",
19 | response_model=List[NoteOutSchema],
20 | dependencies=[Depends(get_current_user)],
21 | )
22 | async def get_notes():
23 | return await crud.get_notes()
24 |
25 |
26 | @router.get(
27 | "/note/{note_id}",
28 | response_model=NoteOutSchema,
29 | dependencies=[Depends(get_current_user)],
30 | )
31 | async def get_note(note_id: int) -> NoteOutSchema:
32 | try:
33 | return await crud.get_note(note_id)
34 | except DoesNotExist:
35 | raise HTTPException(
36 | status_code=404,
37 | detail="Note does not exist",
38 | )
39 |
40 |
41 | @router.post(
42 | "/notes", response_model=NoteOutSchema, dependencies=[Depends(get_current_user)]
43 | )
44 | async def create_note(
45 | note: NoteInSchema, current_user: UserOutSchema = Depends(get_current_user)
46 | ) -> NoteOutSchema:
47 | return await crud.create_note(note, current_user)
48 |
49 |
50 | @router.patch(
51 | "/note/{note_id}",
52 | dependencies=[Depends(get_current_user)],
53 | response_model=NoteOutSchema,
54 | responses={404: {"model": HTTPNotFoundError}},
55 | )
56 | async def update_note(
57 | note_id: int,
58 | note: UpdateNote,
59 | current_user: UserOutSchema = Depends(get_current_user),
60 | ) -> NoteOutSchema:
61 | return await crud.update_note(note_id, note, current_user)
62 |
63 |
64 | @router.delete(
65 | "/note/{note_id}",
66 | response_model=Status,
67 | responses={404: {"model": HTTPNotFoundError}},
68 | dependencies=[Depends(get_current_user)],
69 | )
70 | async def delete_note(
71 | note_id: int, current_user: UserOutSchema = Depends(get_current_user)
72 | ):
73 | return await crud.delete_note(note_id, current_user)
74 |
--------------------------------------------------------------------------------
/services/backend/src/routes/users.py:
--------------------------------------------------------------------------------
1 | from datetime import timedelta
2 |
3 | from fastapi import APIRouter, Depends, HTTPException, status
4 | from fastapi.encoders import jsonable_encoder
5 | from fastapi.responses import JSONResponse
6 | from fastapi.security import OAuth2PasswordRequestForm
7 |
8 | from tortoise.contrib.fastapi import HTTPNotFoundError
9 |
10 | import src.crud.users as crud
11 | from src.auth.users import validate_user
12 | from src.schemas.token import Status
13 | from src.schemas.users import UserInSchema, UserOutSchema
14 |
15 | from src.auth.jwthandler import (
16 | create_access_token,
17 | get_current_user,
18 | ACCESS_TOKEN_EXPIRE_MINUTES,
19 | )
20 |
21 |
22 | router = APIRouter()
23 |
24 |
25 | @router.post("/register", response_model=UserOutSchema)
26 | async def create_user(user: UserInSchema) -> UserOutSchema:
27 | return await crud.create_user(user)
28 |
29 |
30 | @router.post("/login")
31 | async def login(user: OAuth2PasswordRequestForm = Depends()):
32 | user = await validate_user(user)
33 |
34 | if not user:
35 | raise HTTPException(
36 | status_code=status.HTTP_401_UNAUTHORIZED,
37 | detail="Incorrect username or password",
38 | headers={"WWW-Authenticate": "Bearer"},
39 | )
40 |
41 | access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
42 | access_token = create_access_token(
43 | data={"sub": user.username}, expires_delta=access_token_expires
44 | )
45 | token = jsonable_encoder(access_token)
46 | content = {"message": "You've successfully logged in. Welcome back!"}
47 | response = JSONResponse(content=content)
48 | response.set_cookie(
49 | "Authorization",
50 | value=f"Bearer {token}",
51 | httponly=True,
52 | max_age=1800,
53 | expires=1800,
54 | samesite="Lax",
55 | secure=False,
56 | )
57 |
58 | return response
59 |
60 |
61 | @router.get(
62 | "/users/whoami", response_model=UserOutSchema, dependencies=[Depends(get_current_user)]
63 | )
64 | async def read_users_me(current_user: UserOutSchema = Depends(get_current_user)):
65 | return current_user
66 |
67 |
68 | @router.delete(
69 | "/user/{user_id}",
70 | response_model=Status,
71 | responses={404: {"model": HTTPNotFoundError}},
72 | dependencies=[Depends(get_current_user)],
73 | )
74 | async def delete_user(
75 | user_id: int, current_user: UserOutSchema = Depends(get_current_user)
76 | ) -> Status:
77 | return await crud.delete_user(user_id, current_user)
78 |
--------------------------------------------------------------------------------
/services/backend/src/auth/jwthandler.py:
--------------------------------------------------------------------------------
1 | import os
2 | from datetime import datetime, timedelta
3 | from typing import Optional
4 |
5 | from fastapi import Depends, HTTPException, Request
6 | from fastapi.openapi.models import OAuthFlows as OAuthFlowsModel
7 | from fastapi.security import OAuth2
8 | from fastapi.security.utils import get_authorization_scheme_param
9 | from jose import JWTError, jwt
10 | from tortoise.exceptions import DoesNotExist
11 |
12 | from src.schemas.token import TokenData
13 | from src.schemas.users import UserOutSchema
14 | from src.database.models import Users
15 |
16 |
17 | SECRET_KEY = os.environ.get("SECRET_KEY")
18 | ALGORITHM = "HS256"
19 | ACCESS_TOKEN_EXPIRE_MINUTES = 30
20 |
21 |
22 | class OAuth2PasswordBearerCookie(OAuth2):
23 | def __init__(
24 | self,
25 | token_url: str,
26 | scheme_name: str = None,
27 | scopes: dict = None,
28 | auto_error: bool = True,
29 | ):
30 | if not scopes:
31 | scopes = {}
32 | flows = OAuthFlowsModel(password={"tokenUrl": token_url, "scopes": scopes})
33 | super().__init__(flows=flows, scheme_name=scheme_name, auto_error=auto_error)
34 |
35 | async def __call__(self, request: Request) -> Optional[str]:
36 | authorization: str = request.cookies.get("Authorization")
37 | scheme, param = get_authorization_scheme_param(authorization)
38 |
39 | if not authorization or scheme.lower() != "bearer":
40 | if self.auto_error:
41 | raise HTTPException(
42 | status_code=401,
43 | detail="Not authenticated",
44 | headers={"WWW-Authenticate": "Bearer"},
45 | )
46 | else:
47 | return None
48 |
49 | return param
50 |
51 |
52 | security = OAuth2PasswordBearerCookie(token_url="/login")
53 |
54 |
55 | def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
56 | to_encode = data.copy()
57 |
58 | if expires_delta:
59 | expire = datetime.utcnow() + expires_delta
60 | else:
61 | expire = datetime.utcnow() + timedelta(minutes=15)
62 |
63 | to_encode.update({"exp": expire})
64 | encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
65 |
66 | return encoded_jwt
67 |
68 |
69 | async def get_current_user(token: str = Depends(security)):
70 | credentials_exception = HTTPException(
71 | status_code=401,
72 | detail="Could not validate credentials",
73 | headers={"WWW-Authenticate": "Bearer"},
74 | )
75 |
76 | try:
77 | payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
78 | username: str = payload.get("sub")
79 | if username is None:
80 | raise credentials_exception
81 | token_data = TokenData(username=username)
82 | except JWTError:
83 | raise credentials_exception
84 |
85 | try:
86 | user = await UserOutSchema.from_queryset_single(
87 | Users.get(username=token_data.username)
88 | )
89 | except DoesNotExist:
90 | raise credentials_exception
91 |
92 | return user
93 |
--------------------------------------------------------------------------------