├── .gitignore ├── LICENSE ├── README.md ├── docker-compose.yml └── services ├── backend ├── Dockerfile ├── migrations │ └── models │ │ └── 0_20221212182213_init.py ├── pyproject.toml ├── requirements.txt └── src │ ├── auth │ ├── jwthandler.py │ └── users.py │ ├── crud │ ├── notes.py │ └── users.py │ ├── database │ ├── config.py │ ├── models.py │ └── register.py │ ├── main.py │ ├── routes │ ├── notes.py │ └── users.py │ └── schemas │ ├── notes.py │ ├── token.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 │ └── NavBar.vue ├── main.js ├── router │ └── index.js ├── store │ ├── index.js │ └── modules │ │ ├── notes.js │ │ └── users.js └── views │ ├── AboutView.vue │ ├── DashboardView.vue │ ├── EditNoteView.vue │ ├── HomeView.vue │ ├── LoginView.vue │ ├── NoteView.vue │ ├── ProfileView.vue │ └── RegisterView.vue └── vue.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | /dist/ 4 | env/ 5 | __pycache__ 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 TestDriven.io 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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:15.1 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/backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-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 pyproject.toml . 16 | 17 | COPY src/ . 18 | -------------------------------------------------------------------------------- /services/backend/migrations/models/0_20221212182213_init.py: -------------------------------------------------------------------------------- 1 | from tortoise import BaseDBAsyncClient 2 | 3 | 4 | async def upgrade(db: BaseDBAsyncClient) -> str: 5 | return """ 6 | CREATE TABLE IF NOT EXISTS "users" ( 7 | "id" SERIAL NOT NULL PRIMARY KEY, 8 | "username" VARCHAR(20) NOT NULL UNIQUE, 9 | "full_name" VARCHAR(50), 10 | "password" VARCHAR(128), 11 | "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, 12 | "modified_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP 13 | ); 14 | CREATE TABLE IF NOT EXISTS "notes" ( 15 | "id" SERIAL NOT NULL PRIMARY KEY, 16 | "title" VARCHAR(225) NOT NULL, 17 | "content" TEXT NOT NULL, 18 | "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, 19 | "modified_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, 20 | "author_id" INT NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE 21 | ); 22 | CREATE TABLE IF NOT EXISTS "aerich" ( 23 | "id" SERIAL NOT NULL PRIMARY KEY, 24 | "version" VARCHAR(255) NOT NULL, 25 | "app" VARCHAR(100) NOT NULL, 26 | "content" JSONB NOT NULL 27 | );""" 28 | 29 | 30 | async def downgrade(db: BaseDBAsyncClient) -> str: 31 | return """ 32 | """ 33 | -------------------------------------------------------------------------------- /services/backend/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.aerich] 2 | tortoise_orm = "src.database.config.TORTOISE_ORM" 3 | location = "./migrations" 4 | src_folder = "./." 5 | -------------------------------------------------------------------------------- /services/backend/requirements.txt: -------------------------------------------------------------------------------- 1 | aerich==0.7.1 2 | asyncpg==0.27.0 3 | bcrypt==4.0.1 4 | passlib==1.7.4 5 | fastapi==0.88.0 6 | python-jose==3.3.0 7 | python-multipart==0.0.5 8 | tortoise-orm==0.19.2 9 | uvicorn==0.20.0 10 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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@5.0.8 -g 8 | 9 | COPY package.json . 10 | COPY package-lock.json . 11 | RUN npm install 12 | 13 | CMD ["npm", "run", "serve"] 14 | -------------------------------------------------------------------------------- /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/frontend/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /services/frontend/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "baseUrl": "./", 6 | "moduleResolution": "node", 7 | "paths": { 8 | "@/*": [ 9 | "src/*" 10 | ] 11 | }, 12 | "lib": [ 13 | "esnext", 14 | "dom", 15 | "dom.iterable", 16 | "scripthost" 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /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": "^1.2.1", 12 | "bootstrap": "^5.2.3", 13 | "core-js": "^3.8.3", 14 | "vue": "^3.2.13", 15 | "vue-router": "^4.0.3", 16 | "vuex": "^4.1.0" 17 | }, 18 | "devDependencies": { 19 | "@babel/core": "^7.12.16", 20 | "@babel/eslint-parser": "^7.12.16", 21 | "@vue/cli-plugin-babel": "~5.0.0", 22 | "@vue/cli-plugin-eslint": "~5.0.0", 23 | "@vue/cli-plugin-router": "~5.0.0", 24 | "@vue/cli-service": "~5.0.0", 25 | "eslint": "^7.32.0", 26 | "eslint-plugin-vue": "^8.0.3" 27 | }, 28 | "eslintConfig": { 29 | "root": true, 30 | "env": { 31 | "node": true 32 | }, 33 | "extends": [ 34 | "plugin:vue/vue3-essential", 35 | "eslint:recommended" 36 | ], 37 | "parserOptions": { 38 | "parser": "@babel/eslint-parser" 39 | }, 40 | "rules": {} 41 | }, 42 | "browserslist": [ 43 | "> 1%", 44 | "last 2 versions", 45 | "not dead", 46 | "not ie 11" 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /services/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testdrivenio/fastapi-vue/b1737eed5af50cb2d3ea9305b7faf9236d2a12ac/services/frontend/public/favicon.ico -------------------------------------------------------------------------------- /services/frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /services/frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 19 | 20 | 31 | -------------------------------------------------------------------------------- /services/frontend/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testdrivenio/fastapi-vue/b1737eed5af50cb2d3ea9305b7faf9236d2a12ac/services/frontend/src/assets/logo.png -------------------------------------------------------------------------------- /services/frontend/src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 34 | -------------------------------------------------------------------------------- /services/frontend/src/components/NavBar.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 59 | 60 | 65 | -------------------------------------------------------------------------------- /services/frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import 'bootstrap/dist/css/bootstrap.css'; 2 | import { createApp } from "vue"; 3 | import axios from 'axios'; 4 | 5 | import App from './App.vue'; 6 | import router from './router'; 7 | import store from './store'; 8 | 9 | const app = createApp(App); 10 | 11 | axios.defaults.withCredentials = true; 12 | axios.defaults.baseURL = 'http://localhost:5000/'; // the FastAPI backend 13 | 14 | axios.interceptors.response.use(undefined, function (error) { 15 | if (error) { 16 | const originalRequest = error.config; 17 | if (error.response.status === 401 && !originalRequest._retry) { 18 | originalRequest._retry = true; 19 | store.dispatch('logOut'); 20 | return router.push('/login') 21 | } 22 | } 23 | }); 24 | 25 | app.use(router); 26 | app.use(store); 27 | app.mount("#app"); 28 | -------------------------------------------------------------------------------- /services/frontend/src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router' 2 | import HomeView from '@/views/HomeView.vue'; 3 | import RegisterView from '@/views/RegisterView.vue'; 4 | import LoginView from '@/views/LoginView.vue'; 5 | import DashboardView from '@/views/DashboardView.vue'; 6 | import ProfileView from '@/views/ProfileView.vue'; 7 | import NoteView from '@/views/NoteView.vue'; 8 | import EditNoteView from '@/views/EditNoteView.vue'; 9 | import store from '@/store'; // NEW 10 | 11 | 12 | const routes = [ 13 | { 14 | path: '/', 15 | name: "Home", 16 | component: HomeView, 17 | }, 18 | { 19 | path: '/register', 20 | name: 'Register', 21 | component: RegisterView, 22 | }, 23 | { 24 | path: '/login', 25 | name: 'Login', 26 | component: LoginView, 27 | }, 28 | { 29 | path: '/dashboard', 30 | name: 'Dashboard', 31 | component: DashboardView, 32 | meta: { requiresAuth: true }, 33 | }, 34 | { 35 | path: '/profile', 36 | name: 'Profile', 37 | component: ProfileView, 38 | meta: { requiresAuth: true }, 39 | }, 40 | { 41 | path: '/note/:id', 42 | name: 'Note', 43 | component: NoteView, 44 | meta: { requiresAuth: true }, 45 | props: true, 46 | }, 47 | { 48 | path: '/editnote/:id', 49 | name: 'EditNote', 50 | component: EditNoteView, 51 | meta: { requiresAuth: true }, 52 | props: true, 53 | }, 54 | ] 55 | 56 | const router = createRouter({ 57 | history: createWebHistory(process.env.BASE_URL), 58 | routes, 59 | }); 60 | 61 | router.beforeEach((to, _, next) => { 62 | if (to.matched.some(record => record.meta.requiresAuth)) { 63 | if (store.getters.isAuthenticated) { 64 | next(); 65 | return; 66 | } 67 | next('/login'); 68 | } else { 69 | next(); 70 | } 71 | }); 72 | 73 | export default router; 74 | -------------------------------------------------------------------------------- /services/frontend/src/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore } from "vuex"; 2 | 3 | import notes from './modules/notes'; 4 | import users from './modules/users'; 5 | 6 | export default createStore({ 7 | modules: { 8 | notes, 9 | users, 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /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/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/frontend/src/views/AboutView.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /services/frontend/src/views/DashboardView.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 80 | -------------------------------------------------------------------------------- /services/frontend/src/views/EditNoteView.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 72 | -------------------------------------------------------------------------------- /services/frontend/src/views/HomeView.vue: -------------------------------------------------------------------------------- 1 | 15 | 28 | -------------------------------------------------------------------------------- /services/frontend/src/views/LoginView.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 43 | -------------------------------------------------------------------------------- /services/frontend/src/views/NoteView.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | 46 | -------------------------------------------------------------------------------- /services/frontend/src/views/ProfileView.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 39 | -------------------------------------------------------------------------------- /services/frontend/src/views/RegisterView.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 49 | -------------------------------------------------------------------------------- /services/frontend/vue.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('@vue/cli-service') 2 | module.exports = defineConfig({ 3 | transpileDependencies: true 4 | }) 5 | --------------------------------------------------------------------------------