├── .dockerignore ├── .env ├── .gitignore ├── Dockerfile ├── README.md ├── SECURITY.md ├── docker-compose.yml ├── frontend ├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── babel.config.js ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ └── index.html └── src │ ├── App.vue │ ├── assets │ └── logo.png │ ├── components │ ├── HelloWorld.vue │ ├── Nav.vue │ ├── auth │ │ └── Login.vue │ ├── posts │ │ ├── EditPost.vue │ │ ├── MyPosts.vue │ │ ├── NewPost.vue │ │ └── PublicPosts.vue │ └── users │ │ ├── EditUser.vue │ │ ├── ListUser.vue │ │ └── Register.vue │ └── main.js ├── makefile ├── requirements.txt └── src ├── __init__.py ├── api ├── __init__.py ├── auth_api.py ├── exceptions.py ├── post_api.py └── user_api.py ├── core ├── __init__.py ├── config.py └── db.py ├── main.py ├── models ├── __init__.py ├── post.py └── user.py ├── serializers ├── __init__.py ├── comment.py ├── post.py ├── token.py └── user.py └── tests ├── __init__.py ├── conftest.py └── endpoints ├── __init__.py ├── test_oauth.py ├── test_post.py └── test_user.py /.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | *.pyo 4 | *.pyd 5 | .Python 6 | env 7 | venv 8 | pip-log.txt 9 | pip-delete-this-directory.txt 10 | .tox 11 | .coverage 12 | .coverage.* 13 | .cache 14 | nosetests.xml 15 | coverage.xml 16 | *,cover 17 | *.log 18 | .git 19 | .mypy_cache 20 | .pytest_cache 21 | .hypothesis 22 | 23 | .git 24 | .dockerignore 25 | .gitignore 26 | .github/* 27 | README.md 28 | Dockerfile -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### macOS ### 2 | # General 3 | .DS_Store 4 | .AppleDouble 5 | .LSOverride 6 | 7 | # Icon must end with two \r 8 | Icon 9 | 10 | # Thumbnails 11 | ._* 12 | 13 | # Files that might appear in the root of a volume 14 | .DocumentRevisions-V100 15 | .fseventsd 16 | .Spotlight-V100 17 | .TemporaryItems 18 | .Trashes 19 | .VolumeIcon.icns 20 | .com.apple.timemachine.donotpresent 21 | 22 | # Directories potentially created on remote AFP share 23 | .AppleDB 24 | .AppleDesktop 25 | Network Trash Folder 26 | Temporary Items 27 | .apdisk 28 | 29 | ### OSX ### 30 | # General 31 | 32 | # Icon must end with two \r 33 | 34 | # Thumbnails 35 | 36 | # Files that might appear in the root of a volume 37 | 38 | # Directories potentially created on remote AFP share 39 | 40 | ### Python ### 41 | # Byte-compiled / optimized / DLL files 42 | __pycache__/ 43 | *.py[cod] 44 | *$py.class 45 | 46 | # C extensions 47 | *.so 48 | 49 | # Distribution / packaging 50 | .Python 51 | build/ 52 | develop-eggs/ 53 | dist/ 54 | downloads/ 55 | eggs/ 56 | .eggs/ 57 | lib/ 58 | lib64/ 59 | parts/ 60 | sdist/ 61 | var/ 62 | wheels/ 63 | pip-wheel-metadata/ 64 | share/python-wheels/ 65 | *.egg-info/ 66 | .installed.cfg 67 | *.egg 68 | MANIFEST 69 | 70 | # PyInstaller 71 | # Usually these files are written by a python script from a template 72 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 73 | *.manifest 74 | *.spec 75 | 76 | # Installer logs 77 | pip-log.txt 78 | pip-delete-this-directory.txt 79 | 80 | # Unit test / coverage reports 81 | htmlcov/ 82 | .tox/ 83 | .nox/ 84 | .coverage 85 | .coverage.* 86 | .cache 87 | nosetests.xml 88 | coverage.xml 89 | *.cover 90 | .hypothesis/ 91 | .pytest_cache/ 92 | 93 | # Translations 94 | *.mo 95 | *.pot 96 | 97 | # Scrapy stuff: 98 | .scrapy 99 | 100 | # Sphinx documentation 101 | docs/_build/ 102 | 103 | # PyBuilder 104 | target/ 105 | 106 | # pyenv 107 | .python-version 108 | 109 | # pipenv 110 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 111 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 112 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 113 | # install all needed dependencies. 114 | #Pipfile.lock 115 | 116 | # celery beat schedule file 117 | celerybeat-schedule 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Spyder project settings 123 | .spyderproject 124 | .spyproject 125 | 126 | # Rope project settings 127 | .ropeproject 128 | 129 | # Mr Developer 130 | .mr.developer.cfg 131 | .project 132 | .pydevproject 133 | 134 | # mkdocs documentation 135 | /site 136 | 137 | # mypy 138 | .mypy_cache/ 139 | .dmypy.json 140 | dmypy.json 141 | 142 | # Pyre type checker 143 | .pyre/ 144 | 145 | ### VirtualEnv ### 146 | pyvenv.cfg 147 | .env 148 | .venv 149 | env/ 150 | venv/ 151 | ENV/ 152 | env.bak/ 153 | venv.bak/ 154 | pip-selfcheck.json 155 | 156 | 157 | # Editor 158 | .vscode 159 | .idea 160 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # pull official base image 2 | FROM python:3.8.5 3 | 4 | # set work directory 5 | WORKDIR /home/app/backend 6 | 7 | # set environment variables 8 | ENV PYTHONDONTWRITEBYTECODE 1 9 | ENV PYTHONUNBUFFERED 1 10 | 11 | # install dependencies 12 | RUN pip install --upgrade pip 13 | COPY ./requirements.txt . 14 | RUN pip install -r requirements.txt 15 | 16 | # copy project 17 | COPY src ./src -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blog API with FastAPI 2 | 3 | Simple project that I write using fastapi, motor, and umongo for the backend. VueJS on the frontend. 4 | 5 | ---------------------------- 6 | ## Commands 7 | 8 | ### Run 9 | Run the app using the following command: 10 | 11 | ``` 12 | docker-compose up -d --build 13 | # or 14 | make up 15 | ``` 16 | 17 | 18 | ### Remove container 19 | remove container 20 | 21 | ``` 22 | docker-compose down 23 | # or 24 | make down 25 | 26 | # delete all previous containers and volume included 27 | docker-compose down -v --remove-orphans 28 | # or 29 | make down-all 30 | ``` 31 | 32 | ### Log Monitor 33 | 34 | Monitor log for all services (mongodb, backend/api, frontend/vuejs) 35 | ``` 36 | docker-compose logs -f 37 | # or 38 | make logs 39 | ``` 40 | 41 | Monitor log for backend service only 42 | ``` 43 | docker-compose logs -f backend 44 | # or 45 | make logs-backend 46 | ``` 47 | 48 | ### Run Test for the backend service 49 | ``` 50 | docker-compose exec backend pytest 51 | # or 52 | make test-backend 53 | ``` 54 | 55 | ---------------------------- 56 | 57 | The API Documentation url: 58 | http://127.0.0.1:8000/docs 59 | 60 | 61 | The Blog: 62 | http://127.0.0.1:8080 63 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 5.1.x | :white_check_mark: | 11 | | 5.0.x | :x: | 12 | | 4.0.x | :white_check_mark: | 13 | | < 4.0 | :x: | 14 | 15 | ## Reporting a Vulnerability 16 | 17 | Use this section to tell people how to report a vulnerability. 18 | 19 | Tell them where to go, how often they can expect to get an update on a 20 | reported vulnerability, what to expect if the vulnerability is accepted or 21 | declined, etc. 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | backend: 5 | build: . 6 | command: uvicorn src.main:app --reload --host 0.0.0.0 --port 8000 7 | environment: 8 | BASE_PATH_API: /api 9 | SECRET_KEY: secretkey 10 | ALLOWED_HOSTS: 127.0.0.1,localhost 11 | MONGODB_NAME: blog-fastapi 12 | MONGODB_URI: mongodb://mongodbuser:mongodbpassword@mongodb:27017 13 | volumes: 14 | - ./src/:/home/app/backend/src 15 | env_file: 16 | - ./.env 17 | depends_on: 18 | - mongodb 19 | ports: 20 | - "8000:8000" 21 | frontend: 22 | build: ./frontend 23 | volumes: 24 | - ./frontend:/home/app/frontend 25 | - /home/app/frontend/node_modules 26 | depends_on: 27 | - backend 28 | ports: 29 | - "8080:8080" 30 | mongodb: 31 | image: mongo:4.0.8 32 | restart: unless-stopped 33 | command: mongod --auth 34 | environment: 35 | MONGO_INITDB_ROOT_USERNAME: mongodbuser 36 | MONGO_INITDB_ROOT_PASSWORD: mongodbpassword 37 | MONGO_INITDB_DATABASE: blog-fastapi 38 | MONGODB_DATA_DIR: /data/db 39 | MONDODB_LOG_DIR: /dev/null 40 | volumes: 41 | - mongodbdata:/data/db 42 | 43 | 44 | volumes: 45 | mongodbdata: -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .dockerignore 3 | .gitignore 4 | .github/* 5 | README.md 6 | Dockerfile 7 | 8 | # Artifacts that will be built during image creation. 9 | # This should contain all files created during `yarn build`. 10 | dist 11 | node_modules -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | WORKDIR /home/app/frontend 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm install 8 | 9 | EXPOSE 8080 10 | 11 | CMD ["npm", "run", "serve"] -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # frontend 2 | 3 | ## Project setup 4 | ``` 5 | yarn install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | yarn run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | yarn run build 16 | ``` 17 | 18 | ### Run your tests 19 | ``` 20 | yarn run test 21 | ``` 22 | 23 | ### Lints and fixes files 24 | ``` 25 | yarn run lint 26 | ``` 27 | 28 | ### Customize configuration 29 | See [Configuration Reference](https://cli.vuejs.org/config/). 30 | -------------------------------------------------------------------------------- /frontend/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /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.2", 12 | "bootstrap": "^4.4.1", 13 | "core-js": "^3.6.4", 14 | "nprogress": "^0.2.0", 15 | "vue": "^2.6.11", 16 | "vue-axios": "^2.1.5", 17 | "vue-router": "^3.1.6" 18 | }, 19 | "devDependencies": { 20 | "@vue/cli-plugin-babel": "^4.2.0", 21 | "@vue/cli-plugin-eslint": "^4.2.0", 22 | "@vue/cli-service": "^4.2.0", 23 | "babel-eslint": "^10.0.3", 24 | "eslint": "^6.7.2", 25 | "eslint-plugin-vue": "^6.1.2", 26 | "vue-template-compiler": "^2.6.11" 27 | }, 28 | "eslintConfig": { 29 | "root": true, 30 | "env": { 31 | "node": true 32 | }, 33 | "extends": [ 34 | "plugin:vue/essential", 35 | "eslint:recommended" 36 | ], 37 | "parserOptions": { 38 | "parser": "babel-eslint" 39 | }, 40 | "rules": {} 41 | }, 42 | "browserslist": [ 43 | "> 1%", 44 | "last 2 versions" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dwisulfahnur/blog-fastapi-vuejs/66c05d5bc0e78df98a1e294568a9f0af16a0e704/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Blog 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 22 | 23 | -------------------------------------------------------------------------------- /frontend/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dwisulfahnur/blog-fastapi-vuejs/66c05d5bc0e78df98a1e294568a9f0af16a0e704/frontend/src/assets/logo.png -------------------------------------------------------------------------------- /frontend/src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 41 | 42 | 43 | 59 | -------------------------------------------------------------------------------- /frontend/src/components/Nav.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 63 | 64 | -------------------------------------------------------------------------------- /frontend/src/components/auth/Login.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 74 | 75 | -------------------------------------------------------------------------------- /frontend/src/components/posts/EditPost.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 96 | 97 | -------------------------------------------------------------------------------- /frontend/src/components/posts/MyPosts.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 74 | 75 | -------------------------------------------------------------------------------- /frontend/src/components/posts/NewPost.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 74 | 75 | -------------------------------------------------------------------------------- /frontend/src/components/posts/PublicPosts.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 34 | 35 | -------------------------------------------------------------------------------- /frontend/src/components/users/EditUser.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /frontend/src/components/users/ListUser.vue: -------------------------------------------------------------------------------- 1 | 34 | -------------------------------------------------------------------------------- /frontend/src/components/users/Register.vue: -------------------------------------------------------------------------------- 1 | 41 | -------------------------------------------------------------------------------- /frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueRouter from 'vue-router' 3 | import VueAxios from 'vue-axios' 4 | import axios from 'axios' 5 | import NProgress from 'nprogress' 6 | 7 | import App from './App.vue' 8 | import CreateUser from './components/users/Register' 9 | import EditUser from './components/users/EditUser' 10 | import Login from './components/auth/Login' 11 | import 'bootstrap/dist/css/bootstrap.css' 12 | import MyPosts from "./components/posts/MyPosts"; 13 | import PublicPosts from "./components/posts/PublicPosts"; 14 | import NewPost from "./components/posts/NewPost"; 15 | import EditPost from "./components/posts/EditPost"; 16 | // import ListUser from './components/users/ListUser' 17 | 18 | 19 | Vue.use(VueRouter) 20 | Vue.use(VueAxios, axios.create({ 21 | baseURL: 'http://localhost:8000/api', 22 | headers: { 23 | Authorization: `Bearer ${localStorage.getItem('actoken')}` 24 | } 25 | })) 26 | 27 | Vue.config.productionTip = false 28 | 29 | 30 | const routes = [ 31 | // { 32 | // name: 'user-list', 33 | // path: '/users/', 34 | // component: ListUser 35 | // }, 36 | { 37 | name: 'Home', 38 | path: '/', 39 | component: PublicPosts 40 | }, 41 | { 42 | name: 'login', 43 | path: '/login', 44 | component: Login 45 | }, 46 | { 47 | name: 'register', 48 | path: '/register', 49 | component: CreateUser 50 | }, 51 | { 52 | name: 'user-edit', 53 | path: '/users/edit', 54 | component: EditUser 55 | }, 56 | { 57 | name: 'new-post', 58 | path: '/post/new', 59 | component: NewPost 60 | }, 61 | { 62 | name: 'my-posts', 63 | path: '/post/me', 64 | component: MyPosts, 65 | }, 66 | { 67 | name: 'edit-post', 68 | path: '/post/:id', 69 | component: EditPost 70 | } 71 | ] 72 | 73 | 74 | const router = new VueRouter({mode: 'history', routes: routes}) 75 | 76 | router.beforeResolve((to, from, next) => { 77 | if (to.name) { 78 | NProgress.start() 79 | } 80 | next() 81 | }) 82 | 83 | router.afterEach(() => { 84 | NProgress.done() 85 | }) 86 | 87 | new Vue({ 88 | render: h => h(App), 89 | router 90 | }).$mount('#app') -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | up: 2 | docker-compose up -d --build 3 | 4 | down: 5 | docker-compose down 6 | 7 | down-all: 8 | docker-compose down -v --remove-orphans 9 | 10 | logs: 11 | docker-compose logs -f 12 | 13 | logs-backend: 14 | docker-compose logs -f backend 15 | 16 | test-backend: 17 | docker-compose exec backend pytest -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | attrs>=19.3.0 2 | bcrypt>=3.1.7 3 | certifi>=2019.11.28 4 | cffi>=1.14.0 5 | chardet>=3.0.4 6 | Click>=7.0 7 | coverage>=5.0.3 8 | dnspython>=1.16.0 9 | email-validator>=1.0.5 10 | fastapi>=0.49.0 11 | h11>=0.9.0 12 | httptools>=0.1.1 13 | idna>=2.9 14 | importlib-metadata>=1.5.0 15 | marshmallow>=2.20.5 16 | more-itertools>=8.2.0 17 | motor>=2.1.0 18 | packaging>=20.1 19 | passlib>=1.7.2 20 | pluggy>=0.13.1 21 | py>=1.8.1 22 | pycparser>=2.20 23 | pydantic>=1.4 24 | PyJWT>=1.7.1 25 | pymongo>=3.10.1 26 | pyparsing>=2.4.6 27 | pytest>=5.3.5 28 | python-dateutil>=2.8.1 29 | python-multipart>=0.0.5 30 | requests>=2.23.0 31 | six>=1.14.0 32 | starlette>=0.12.9 33 | tests>=0.7 34 | umongo>=3.0.0 35 | urllib3>=1.25.8 36 | uvicorn>=0.11.7 37 | uvloop>=0.14.0 38 | wcwidth>=0.1.8 39 | websockets>=8.1 40 | zipp>=3.0.0 41 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dwisulfahnur/blog-fastapi-vuejs/66c05d5bc0e78df98a1e294568a9f0af16a0e704/src/__init__.py -------------------------------------------------------------------------------- /src/api/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from .auth_api import router as oauth_router 4 | from .post_api import router as post_router 5 | from .user_api import router as user_router 6 | 7 | router = APIRouter() 8 | 9 | router.include_router(prefix="/oauth", router=oauth_router, tags=['OAuth2']) 10 | router.include_router(prefix="/users", router=user_router, tags=['Users']) 11 | router.include_router(prefix="/posts", router=post_router, tags=['Posts']) 12 | -------------------------------------------------------------------------------- /src/api/auth_api.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | import jwt 4 | from fastapi import APIRouter, Depends, HTTPException 5 | from fastapi.security import OAuth2PasswordBearer 6 | from fastapi.security import OAuth2PasswordRequestForm 7 | from starlette import status 8 | 9 | from src.core.config import ACCESS_TOKEN_EXPIRES, SECRET_KEY, ALGORITHM 10 | from src.models.user import User 11 | from src.serializers.token import Token, TokenData 12 | 13 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/api/oauth/token') 14 | router = APIRouter() 15 | 16 | 17 | async def get_current_user(token: str = Depends(oauth2_scheme)) -> User: 18 | credentials_exception = HTTPException( 19 | status_code=status.HTTP_401_UNAUTHORIZED, 20 | detail="Could not validate credentials", 21 | headers={"WWW-Authenticate": "Bearer"}, 22 | ) 23 | try: 24 | payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) 25 | sub: str = payload.get("sub") 26 | if sub is None: 27 | raise credentials_exception 28 | token_data = TokenData(sub=sub) 29 | except jwt.PyJWTError: 30 | raise credentials_exception 31 | user = await User.get(token_data.sub) 32 | if user is None: 33 | raise credentials_exception 34 | return user 35 | 36 | 37 | @router.post('/token', response_model=Token) 38 | async def get_token_api(form_data: OAuth2PasswordRequestForm = Depends()): 39 | user = await User.get_by_email(form_data.username) 40 | if not user: 41 | raise HTTPException(status_code=400, detail="Incorrect username or password") 42 | 43 | if not user.check_password(form_data.password): 44 | raise HTTPException(status_code=400, detail="Incorrect username or password") 45 | 46 | access_token = user.create_access_token(expires_delta=timedelta(hours=ACCESS_TOKEN_EXPIRES)) 47 | return {"access_token": access_token, "token_type": "bearer"} 48 | 49 | 50 | @router.get('/tokeninfo', status_code=status.HTTP_200_OK) 51 | async def get_token_info_api(token: str = None): 52 | credentials_exception = HTTPException( 53 | detail='Token is not valid', 54 | status_code=status.HTTP_401_UNAUTHORIZED) 55 | if not token: raise credentials_exception 56 | try: 57 | payload = jwt.decode(token.encode(), SECRET_KEY, algorithms=[ALGORITHM]) 58 | sub: str = payload.get("sub") 59 | if sub is None or not await User.get(sub): 60 | raise credentials_exception 61 | except jwt.PyJWTError: 62 | raise credentials_exception 63 | return payload 64 | -------------------------------------------------------------------------------- /src/api/exceptions.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dwisulfahnur/blog-fastapi-vuejs/66c05d5bc0e78df98a1e294568a9f0af16a0e704/src/api/exceptions.py -------------------------------------------------------------------------------- /src/api/post_api.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List 3 | 4 | from bson.objectid import ObjectId 5 | from fastapi import APIRouter, HTTPException, Depends 6 | from starlette import status 7 | 8 | from src.api.auth_api import get_current_user 9 | from src.models.post import Post, Comment 10 | from src.models.user import User 11 | from src.serializers.comment import CommentInSerializer 12 | from src.serializers.post import PostInSerializer, PostSerializer, PostInPatchSerializer 13 | 14 | router = APIRouter() 15 | 16 | 17 | def pagination(skip: int = 0, limit: int = 10): 18 | return { 19 | 'skip': skip, 20 | 'limit': limit 21 | } 22 | 23 | 24 | @router.get("", response_model=List[PostSerializer], 25 | status_code=status.HTTP_200_OK) 26 | async def get_posts_api(pagination=Depends(pagination)): 27 | cursor = Post.find() \ 28 | .sort('created_at', -1) \ 29 | .skip(pagination['skip']) \ 30 | .limit(pagination['limit']) 31 | 32 | posts = [post.dump() for post in await cursor.to_list(length=pagination['limit'])] 33 | return posts 34 | 35 | 36 | @router.get("/me", response_model=List[PostSerializer]) 37 | async def get_my_posts_api(pagination=Depends(pagination), current_user=Depends(get_current_user)): 38 | cursor = Post.find({'created_by': current_user.id}) \ 39 | .sort('created_at', -1) \ 40 | .skip(pagination['skip']) \ 41 | .limit(pagination['limit']) 42 | 43 | posts = [post.dump() for post in await cursor.to_list(length=pagination['limit'])] 44 | return posts 45 | 46 | 47 | @router.post("", response_model=PostSerializer, 48 | status_code=status.HTTP_201_CREATED) 49 | async def create_post_api(post: PostInSerializer, current_user:User=Depends(get_current_user)): 50 | post = Post( 51 | title=post.title, 52 | created_by=current_user.id, 53 | content=post.content, 54 | comments=[], 55 | author_name=current_user.full_name 56 | ) 57 | 58 | await post.commit() 59 | post = post.dump() 60 | return post 61 | 62 | 63 | @router.get("/{post_id}", response_model=PostSerializer, 64 | status_code=status.HTTP_201_CREATED) 65 | async def get_post_detail_api(post_id: str): 66 | post = await Post.find_one({"_id": ObjectId(post_id)}) 67 | if post: 68 | return post.dump() 69 | else: 70 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Post Not Found") 71 | 72 | 73 | @router.delete("/{post_id}", status_code=status.HTTP_204_NO_CONTENT) 74 | async def delete_post_api(post_id: str, current_user=Depends(get_current_user)): 75 | post = await Post.get(post_id) 76 | if not post: 77 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Post Not Found") 78 | 79 | if post.created_by != current_user: 80 | raise HTTPException(status_code=401, detail='Not Enough Permissions') 81 | 82 | await post.remove() 83 | return 84 | 85 | 86 | @router.patch("/{post_id}", response_model=PostSerializer, status_code=status.HTTP_200_OK) 87 | async def update_post_api(post_id: str, post_in: PostInPatchSerializer, current_user=Depends(get_current_user)): 88 | post = await Post.get(post_id) 89 | if not post: 90 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Post Not Found") 91 | 92 | if post.created_by != current_user: 93 | raise HTTPException(status_code=401, detail='Not Enough Permissions') 94 | 95 | if post_in.title: 96 | post.title = post_in.title 97 | if post_in.content: 98 | post.content = post_in.content 99 | 100 | post.updated_at = datetime.now() 101 | await post.commit() 102 | return post.dump() 103 | 104 | 105 | @router.post("/{post_id}/add-comment", response_model=PostSerializer, 106 | status_code=status.HTTP_201_CREATED) 107 | async def add_post_comment_api(post_id: str, comment: CommentInSerializer, 108 | current_user: User = Depends(get_current_user)): 109 | post = await Post.find_one({"_id": ObjectId(post_id)}) 110 | 111 | if post: 112 | comment = Comment(created_by=current_user, content=comment.content) 113 | post.add_comment(comment) 114 | await post.commit() 115 | return post.dump() 116 | 117 | if not post: 118 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, 119 | detail="Post Not Found") 120 | -------------------------------------------------------------------------------- /src/api/user_api.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from bson.objectid import ObjectId 4 | from fastapi import APIRouter, HTTPException, Depends 5 | from marshmallow.exceptions import ValidationError 6 | from starlette import status 7 | from starlette.responses import JSONResponse 8 | 9 | from src.models.user import User 10 | from src.serializers.user import UserInSerializer, UserSerializer 11 | from .auth_api import get_current_user 12 | 13 | router = APIRouter() 14 | 15 | 16 | @router.get("", response_model=List[UserSerializer], 17 | status_code=status.HTTP_200_OK) 18 | async def get_users_api(_: User = Depends(get_current_user)): 19 | cursor = User.find() 20 | return [user.dump() for user in await cursor.to_list(length=10)] 21 | 22 | 23 | @router.post("", response_model=UserSerializer, 24 | status_code=status.HTTP_201_CREATED) 25 | async def create_user_api(user_in: UserInSerializer): 26 | user_exists = await User.get_by_email(user_in.email) 27 | if user_exists: 28 | raise HTTPException( 29 | 422, 30 | [{ 31 | 'loc': ["body", 'email'], 32 | 'msg': f"Email '{user_in.email}' is already registered", 33 | 'type': 'value_error', 34 | }] 35 | ) 36 | try: 37 | user = await User.register_new_user(email=user_in.email, 38 | full_name=user_in.full_name, 39 | password=user_in.password) 40 | return user.dump() 41 | except ValidationError as e: 42 | return JSONResponse({'field_errors': [e.messages]}, status.HTTP_400_BAD_REQUEST) 43 | 44 | 45 | @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) 46 | async def delete_user_api(user_id: str, current_user: User = Depends(get_current_user)): 47 | user = await User.find_one({'_id': ObjectId(user_id)}, {'_id': 1}) 48 | if not user: 49 | raise HTTPException(detail="User Not Found", status_code=status.HTTP_404_NOT_FOUND) 50 | await user.remove() 51 | 52 | 53 | @router.get('/me', response_model=UserSerializer, status_code=status.HTTP_200_OK) 54 | async def get_user_me_api(current_user: User = Depends(get_current_user)): 55 | return current_user.dump() 56 | -------------------------------------------------------------------------------- /src/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dwisulfahnur/blog-fastapi-vuejs/66c05d5bc0e78df98a1e294568a9f0af16a0e704/src/core/__init__.py -------------------------------------------------------------------------------- /src/core/config.py: -------------------------------------------------------------------------------- 1 | from passlib.context import CryptContext 2 | from starlette.config import Config, environ 3 | from starlette.datastructures import CommaSeparatedStrings 4 | 5 | config = Config(".env") 6 | 7 | BASE_PATH_API = config('BASE_PATH_API') 8 | 9 | SECRET_KEY = config('SECRET_KEY', default='secretkey') 10 | ALGORITHM = "HS256" 11 | PWD_CONTEXT = CryptContext(schemes=["bcrypt"], deprecated="auto") 12 | 13 | ALLOWED_HOSTS = config('ALLOWED_HOSTS', cast=CommaSeparatedStrings, default=['*']) 14 | 15 | DEBUG = config('DEBUG', cast=bool, default=False) 16 | 17 | ACCESS_TOKEN_EXPIRES = config('ACCESS_TOKEN_EXPIRES', default=24 * 7) 18 | MONGODB_NAME = config("MONGODB_NAME") 19 | MONGODB_URI = config("MONGODB_URI") 20 | 21 | database_name = MONGODB_NAME 22 | if environ.get('TESTING') == 'TRUE': 23 | database_name = f'test-{database_name}' 24 | -------------------------------------------------------------------------------- /src/core/db.py: -------------------------------------------------------------------------------- 1 | from src.core import config 2 | from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase 3 | from umongo.frameworks import MotorAsyncIOInstance 4 | 5 | 6 | class DataBase: 7 | client: AsyncIOMotorClient = None 8 | 9 | 10 | database = DataBase() 11 | 12 | 13 | def get_database() -> AsyncIOMotorDatabase: 14 | if database.client: 15 | return database.client[config.database_name] 16 | return AsyncIOMotorClient(config.MONGODB_URI)[config.database_name] 17 | 18 | 19 | def get_client() -> AsyncIOMotorClient: 20 | if not database.client: 21 | return AsyncIOMotorClient(config.MONGODB_URI) 22 | return database.client 23 | 24 | 25 | db = get_database() 26 | instance = MotorAsyncIOInstance(db) 27 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi import FastAPI 4 | from starlette.middleware.cors import CORSMiddleware 5 | from motor.motor_asyncio import AsyncIOMotorClient 6 | 7 | from src.api import router 8 | from src.core import config 9 | from src.core.db import database 10 | 11 | app = FastAPI(title="Blog API") 12 | 13 | origins= [ 14 | 'http://localhost:8000', 15 | 'http://localhost:8080', 16 | 'http://127.0.0.1:8000', 17 | 'http://127.0.0.1:8080' 18 | ] 19 | 20 | app.add_middleware( 21 | CORSMiddleware, 22 | allow_origins=origins, 23 | allow_credentials=True, 24 | allow_methods=['*'], 25 | allow_headers=['*'], 26 | ) 27 | 28 | 29 | @app.on_event("startup") 30 | async def event_startup(): 31 | from src.core.db import database 32 | logging.info("connect to database....") 33 | database.client = AsyncIOMotorClient(str(config.MONGODB_URI)) 34 | logging.info("Connected to database!") 35 | 36 | logging.info("ensuring model indexes") 37 | from src.models import ensure_indexes 38 | await ensure_indexes() 39 | 40 | 41 | @app.on_event("shutdown") 42 | async def event_shutdown(): 43 | logging.info("close mongodb connection....") 44 | database.client.close() 45 | logging.info("connection to mongodb has been closed!") 46 | 47 | 48 | app.include_router(router, prefix=config.BASE_PATH_API) 49 | -------------------------------------------------------------------------------- /src/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .post import Post 2 | from .user import User 3 | 4 | 5 | async def ensure_indexes(): 6 | await User.ensure_indexes() 7 | await Post.ensure_indexes() 8 | -------------------------------------------------------------------------------- /src/models/post.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional 3 | 4 | from bson.objectid import ObjectId 5 | from umongo import Document, fields, EmbeddedDocument 6 | 7 | from src.core.db import instance, db 8 | from src.models.user import User 9 | 10 | 11 | @instance.register 12 | class Comment(EmbeddedDocument): 13 | id = fields.ObjectIdField(default=ObjectId()) 14 | content = fields.StrField() 15 | 16 | created_by = fields.ReferenceField("User") 17 | created_at = fields.DateTimeField(default=datetime.now) 18 | updated_at = fields.DateTimeField(default=datetime.now) 19 | 20 | 21 | @instance.register 22 | class Post(Document): 23 | title = fields.StrField() 24 | content = fields.StrField() 25 | comments = fields.ListField(fields.EmbeddedField(Comment)) 26 | 27 | author_name = fields.StrField(allow_none=True, default='') 28 | created_by = fields.ReferenceField(User) 29 | created_at = fields.DateTimeField(default=datetime.now) 30 | updated_at = fields.DateTimeField(default=datetime.now) 31 | 32 | class Meta: 33 | collection = db.post 34 | 35 | @classmethod 36 | async def get(cls, id: str) -> Optional['Post']: 37 | if not ObjectId.is_valid(id): 38 | return None 39 | 40 | return await cls.find_one({'_id': ObjectId(id)}) 41 | 42 | def add_comment(self, comment: Comment): 43 | self.comments = self.comments + [comment] 44 | -------------------------------------------------------------------------------- /src/models/user.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import jwt 4 | from bson.objectid import ObjectId 5 | from src.core.config import SECRET_KEY, PWD_CONTEXT, ALGORITHM 6 | from src.core.db import instance, db 7 | from umongo import Document, fields 8 | 9 | 10 | @instance.register 11 | class User(Document): 12 | id = fields.ObjectIdField() 13 | full_name = fields.StrField() 14 | email = fields.EmailField(unique=True) 15 | hashed_password = fields.StrField() 16 | last_password_updated_at = fields.DateTimeField() 17 | scopes = fields.ListField(fields.StrField(), default=[]) 18 | 19 | created_at = fields.DateTimeField(default=datetime.now) 20 | updated_at = fields.DateTimeField(default=datetime.now) 21 | 22 | class Meta: 23 | collection_name = 'user' 24 | collection = db.user 25 | 26 | @classmethod 27 | async def get(cls, id: str): 28 | if not ObjectId.is_valid(id): 29 | return None 30 | user = await cls.find_one({'_id': ObjectId(id)}) 31 | return user 32 | 33 | @classmethod 34 | async def get_by_email(cls, email: str): 35 | return await cls.find_one({'email': email}) 36 | 37 | def check_password(self, password: str): 38 | if self.hashed_password: 39 | return PWD_CONTEXT.verify(password, self.hashed_password) 40 | 41 | def set_password(self, password: str): 42 | self.hashed_password = PWD_CONTEXT.hash(password) 43 | self.last_password_updated_at = datetime.now() 44 | 45 | def create_access_token(self, expires_delta: timedelta = None): 46 | now = datetime.utcnow() 47 | if expires_delta: 48 | expire = now + expires_delta 49 | else: 50 | expire = now + timedelta(minutes=15) 51 | to_encode = { 52 | 'exp': expire, 53 | 'iat': now, 54 | 'sub': str(self.id), 55 | 'scope': ' '.join(self.scopes) if self.scopes else '' 56 | } 57 | encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) 58 | return encoded_jwt 59 | 60 | @classmethod 61 | async def register_new_user(cls, email: str, full_name: str, password: str): 62 | user = cls(email=email, full_name=full_name) 63 | user.set_password(password) 64 | await user.commit() 65 | return user 66 | -------------------------------------------------------------------------------- /src/serializers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dwisulfahnur/blog-fastapi-vuejs/66c05d5bc0e78df98a1e294568a9f0af16a0e704/src/serializers/__init__.py -------------------------------------------------------------------------------- /src/serializers/comment.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class CommentSerializer(BaseModel): 7 | id: str 8 | content: str 9 | 10 | created_by: str 11 | created_at: datetime 12 | updated_at: datetime 13 | 14 | 15 | class CommentInSerializer(BaseModel): 16 | content: str 17 | -------------------------------------------------------------------------------- /src/serializers/post.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List, Optional 3 | 4 | from pydantic import BaseModel, validator 5 | 6 | from .comment import CommentSerializer 7 | 8 | 9 | class PostSerializer(BaseModel): 10 | id: str 11 | title: str 12 | content: str 13 | comments: List[CommentSerializer] = [] 14 | 15 | author_name: Optional[str] 16 | created_by: str 17 | created_at: datetime 18 | updated_at: datetime 19 | 20 | 21 | class PostInSerializer(BaseModel): 22 | title: str 23 | content: str 24 | 25 | @validator('title') 26 | def validate_title(cls, v): 27 | if not v: 28 | raise ValueError('This field is required') 29 | if len(v) < 5: 30 | raise ValueError('Minimum length of title is 5 chars') 31 | return v 32 | 33 | @validator('content') 34 | def validate_content(cls, v): 35 | if not v: 36 | raise ValueError('This field is required') 37 | if len(v) < 20: 38 | raise ValueError('Minimum length of the body of content is 20 chars') 39 | return v 40 | 41 | 42 | class PostInPatchSerializer(BaseModel): 43 | title: Optional[str] 44 | content: Optional[str] 45 | -------------------------------------------------------------------------------- /src/serializers/token.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class Token(BaseModel): 7 | access_token: str 8 | token_type: str 9 | 10 | 11 | class TokenData(BaseModel): 12 | sub: str = None 13 | scopes: List[str] = [] 14 | -------------------------------------------------------------------------------- /src/serializers/user.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional 3 | 4 | from pydantic import BaseModel, EmailStr, validator 5 | 6 | 7 | class UserSerializer(BaseModel): 8 | id: str 9 | full_name: Optional[str] 10 | email: EmailStr 11 | 12 | created_at: datetime 13 | updated_at: datetime 14 | 15 | class Config: 16 | orm_mode = True 17 | 18 | 19 | class UserInSerializer(BaseModel): 20 | full_name: str 21 | email: EmailStr 22 | password: str 23 | 24 | @validator('full_name') 25 | def validate_full_name(cls, v): 26 | if not v: 27 | raise ValueError('Full Name is required') 28 | return v 29 | 30 | @validator('password') 31 | def validate_password(cls, v): 32 | if not v: 33 | raise ValueError('Password is required to create a new account') 34 | if v and len(v) < 8: 35 | raise ValueError('Password must be more than 8 characters') 36 | return v 37 | -------------------------------------------------------------------------------- /src/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dwisulfahnur/blog-fastapi-vuejs/66c05d5bc0e78df98a1e294568a9f0af16a0e704/src/tests/__init__.py -------------------------------------------------------------------------------- /src/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from starlette.config import environ 3 | from starlette.testclient import TestClient 4 | from datetime import timedelta 5 | environ["TESTING"] = "TRUE" 6 | 7 | 8 | @pytest.fixture(scope="session") 9 | def test_user_data(): 10 | return { 11 | "email": "user1@example.com", 12 | "full_name": "string test1", 13 | "password": "string1-min8", 14 | } 15 | 16 | 17 | @pytest.fixture(scope='session') 18 | def event_loop(): 19 | import asyncio 20 | loop = asyncio.get_event_loop() 21 | return loop 22 | 23 | 24 | @pytest.fixture(scope="session") 25 | def test_user_object_data(event_loop, test_user_data): 26 | from src.models.user import User 27 | user = event_loop.run_until_complete(User.get_by_email(test_user_data['email'])) 28 | if not user: 29 | user = event_loop.run_until_complete( 30 | User.register_new_user( 31 | email=test_user_data['email'], 32 | full_name=test_user_data['full_name'], 33 | password=test_user_data['password'], 34 | )) 35 | return user 36 | 37 | 38 | @pytest.fixture(scope="session") 39 | def access_token(test_user_object_data): 40 | return test_user_object_data.create_access_token(timedelta(hours=1)) 41 | 42 | 43 | @pytest.fixture(scope="session") 44 | def test_client(event_loop): 45 | from src.main import app 46 | from src.core.config import database_name 47 | with TestClient(app) as test_client: 48 | yield test_client 49 | app.dependency_overrides = {} 50 | 51 | # Delete database testing after test already finished 52 | from src.core.db import get_client 53 | client = get_client() 54 | client.drop_database(database_name) 55 | -------------------------------------------------------------------------------- /src/tests/endpoints/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dwisulfahnur/blog-fastapi-vuejs/66c05d5bc0e78df98a1e294568a9f0af16a0e704/src/tests/endpoints/__init__.py -------------------------------------------------------------------------------- /src/tests/endpoints/test_oauth.py: -------------------------------------------------------------------------------- 1 | from src.core.config import BASE_PATH_API 2 | 3 | 4 | def test_obtain_token_api(test_client, test_user_data, test_user_object_data): 5 | response = test_client.post(f'{BASE_PATH_API}/oauth/token', data={ 6 | 'grant_type': 'password', 7 | 'username': test_user_data['email'], 8 | 'password': test_user_data['password'], 9 | }) 10 | 11 | assert response.status_code == 200 12 | assert response.json()['access_token'] 13 | assert response.json()['token_type'] == 'bearer' 14 | 15 | 16 | def test_obtain_token_info_api(test_client, access_token): 17 | response = test_client.get(f'{BASE_PATH_API}/oauth/tokeninfo?token={access_token}') 18 | assert response.status_code == 200 19 | 20 | 21 | def test_obtain_token_info_api_failed(test_client): 22 | response = test_client.get(f'{BASE_PATH_API}/oauth/tokeninfo?token=wrongtoken') 23 | assert response.status_code == 401 24 | assert response.json()['detail'] == 'Token is not valid' -------------------------------------------------------------------------------- /src/tests/endpoints/test_post.py: -------------------------------------------------------------------------------- 1 | 2 | import pytest 3 | 4 | from src.core.config import BASE_PATH_API 5 | 6 | 7 | @pytest.fixture(scope='module') 8 | def test_post_data(): 9 | return { 10 | 'title': 'Test Post title', 11 | 'content': 'Test content of the post' 12 | } 13 | 14 | 15 | @pytest.fixture(scope='module') 16 | def test_create_post(test_client, test_user_object_data, test_post_data, access_token): 17 | response = test_client.post(f'{BASE_PATH_API}/posts', json=test_post_data, headers={ 18 | 'Authorization': f'Bearer {access_token}' 19 | }) 20 | 21 | assert response.status_code == 201 22 | assert response.json()['title'] == test_post_data['title'] 23 | assert response.json()['content'] == test_post_data['content'] 24 | 25 | def test_create_post_fail(test_client, access_token): 26 | response = test_client.post(f'{BASE_PATH_API}/posts', headers={ 27 | 'Authorization': f'Bearer {access_token}' 28 | }) 29 | 30 | assert response.status_code == 422 31 | assert response.json() 32 | 33 | def test_create_post_unauthorized(test_client, test_post_data): 34 | response = test_client.post(f'{BASE_PATH_API}/posts', json=test_post_data, headers={ 35 | 'Authorization': f'Bearer nope' 36 | }) 37 | 38 | assert response.status_code == 401 39 | assert response.json() 40 | 41 | def test_read_post(test_client, test_create_post): 42 | response = test_client.get(f'{BASE_PATH_API}/posts') 43 | 44 | assert response.status_code == 200 45 | assert response.json() 46 | -------------------------------------------------------------------------------- /src/tests/endpoints/test_user.py: -------------------------------------------------------------------------------- 1 | from src.core.config import BASE_PATH_API 2 | 3 | 4 | def test_user_register_api(test_client, test_user_data, test_user_object_data, monkeypatch): 5 | from src.models.user import User 6 | class UserInDBMock(User): 7 | @classmethod 8 | async def register_new_user(cls, full_name: str, 9 | email: str, password: str): 10 | return test_user_object_data 11 | 12 | monkeypatch.setattr('src.models.user.User.register_new_user', 13 | UserInDBMock.register_new_user) 14 | 15 | response = test_client.post(f'{BASE_PATH_API}/users', json={ 16 | 'full_name': test_user_data['full_name'], 17 | 'email': test_user_data['email'], 18 | 'password': test_user_data['password'], 19 | }) 20 | 21 | assert response.status_code == 201 22 | assert response.json()['id'] 23 | assert response.json()['email'] == test_user_data['email'] 24 | 25 | 26 | def test_read_user_me(test_client, test_user_object_data, access_token): 27 | response = test_client.get(f'{BASE_PATH_API}/users/me', headers={ 28 | 'Authorization': f'Bearer {access_token}' 29 | }) 30 | 31 | assert response.status_code == 200 32 | assert response.json()['id'] == str(test_user_object_data.id) 33 | assert response.json()['full_name'] == test_user_object_data.full_name 34 | assert response.json()['email'] == test_user_object_data.email 35 | --------------------------------------------------------------------------------