├── .github ├── dependabot.yml └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── README.md ├── app ├── __init__.py ├── auth │ ├── __init__.py │ └── routes.py ├── comments │ ├── __init__.py │ └── routes.py ├── errors │ ├── __init__.py │ └── handlers.py ├── helpers │ ├── task_helpers.py │ └── test_helpers.py ├── models.py ├── posts │ ├── __init__.py │ └── routes.py ├── schemas.py ├── tasks │ ├── __init__.py │ ├── long_running_jobs.py │ └── routes.py ├── tests │ ├── __init__.py │ ├── test_auth.py │ ├── test_comments.py │ ├── test_errors.py │ ├── test_posts.py │ └── test_users.py └── users │ ├── __init__.py │ └── routes.py ├── config.py ├── flask_api_template.py └── requirements.txt /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Fetch and update latest `pip` packages 4 | - package-ecosystem: pip 5 | directory: '/' 6 | schedule: 7 | interval: weekly 8 | time: '00:00' 9 | open-pull-requests-limit: 10 10 | reviewers: 11 | - stefanvdweide 12 | assignees: 13 | - stefanvdweide 14 | commit-message: 15 | prefix: fix 16 | prefix-development: chore 17 | include: scope 18 | # Fetch and update latest `github-actions` pkgs 19 | - package-ecosystem: github-actions 20 | directory: '/' 21 | schedule: 22 | interval: weekly 23 | time: '00:00' 24 | open-pull-requests-limit: 10 25 | reviewers: 26 | - stefanvdweide 27 | assignees: 28 | - stefanvdweide 29 | commit-message: 30 | prefix: fix 31 | prefix-development: chore 32 | include: scope 33 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Setup Python3 17 | uses: actions/setup-python@v3 18 | with: 19 | python-version: '3.x' 20 | - name: Install dependencies 21 | run: pip3 install -r requirements.txt 22 | - name: Run black to format code --check . 23 | run: black --check . 24 | - name: If needed, commit black changes to the pull request 25 | if: failure() 26 | run: | 27 | black . 28 | git config --global user.name 'autoblack' 29 | git config --global user.email 'cclauss@users.noreply.github.com' 30 | git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/$GITHUB_REPOSITORY 31 | git fetch 32 | git checkout $GITHUB_HEAD_REF 33 | git commit -am "fixup: Format Python code with Black" 34 | git push 35 | - name: Run flask8 to lint code 36 | run: | 37 | # stop the build if there are Python syntax errors or undefined names 38 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 39 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 40 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 41 | - name: Run unittests 42 | run: python3 -m unittest discover -s app/tests/ 43 | env: 44 | FLASK_ENV: testing 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### JetBrains template 3 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 4 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 5 | 6 | # User-specific stuff 7 | .idea/**/workspace.xml 8 | .idea/**/tasks.xml 9 | .idea/**/usage.statistics.xml 10 | .idea/**/dictionaries 11 | .idea/**/shelf 12 | 13 | # Generated files 14 | .idea/**/contentModel.xml 15 | 16 | # Sensitive or high-churn files 17 | .idea/**/dataSources/ 18 | .idea/**/dataSources.ids 19 | .idea/**/dataSources.local.xml 20 | .idea/**/sqlDataSources.xml 21 | .idea/**/dynamic.xml 22 | .idea/**/uiDesigner.xml 23 | .idea/**/dbnavigator.xml 24 | 25 | # Gradle 26 | .idea/**/gradle.xml 27 | .idea/**/libraries 28 | 29 | # Gradle and Maven with auto-import 30 | # When using Gradle or Maven with auto-import, you should exclude module files, 31 | # since they will be recreated, and may cause churn. Uncomment if using 32 | # auto-import. 33 | # .idea/artifacts 34 | # .idea/compiler.xml 35 | # .idea/jarRepositories.xml 36 | # .idea/modules.xml 37 | # .idea/*.iml 38 | # .idea/modules 39 | # *.iml 40 | # *.ipr 41 | 42 | # CMake 43 | cmake-build-*/ 44 | 45 | # Mongo Explorer plugin 46 | .idea/**/mongoSettings.xml 47 | 48 | # File-based project format 49 | *.iws 50 | 51 | # IntelliJ 52 | out/ 53 | 54 | # mpeltonen/sbt-idea plugin 55 | .idea_modules/ 56 | 57 | # JIRA plugin 58 | atlassian-ide-plugin.xml 59 | 60 | # Cursive Clojure plugin 61 | .idea/replstate.xml 62 | 63 | # Crashlytics plugin (for Android Studio and IntelliJ) 64 | com_crashlytics_export_strings.xml 65 | crashlytics.properties 66 | crashlytics-build.properties 67 | fabric.properties 68 | 69 | # Editor-based Rest Client 70 | .idea/httpRequests 71 | 72 | # Android studio 3.1+ serialized cache file 73 | .idea/caches/build_file_checksums.ser 74 | 75 | ### Windows template 76 | # Windows thumbnail cache files 77 | Thumbs.db 78 | Thumbs.db:encryptable 79 | ehthumbs.db 80 | ehthumbs_vista.db 81 | 82 | # Dump file 83 | *.stackdump 84 | 85 | # Folder config file 86 | [Dd]esktop.ini 87 | 88 | # Recycle Bin used on file shares 89 | $RECYCLE.BIN/ 90 | 91 | # Windows Installer files 92 | *.cab 93 | *.msi 94 | *.msix 95 | *.msm 96 | *.msp 97 | 98 | # Windows shortcuts 99 | *.lnk 100 | 101 | ### VisualStudioCode template 102 | .vscode/* 103 | !.vscode/settings.json 104 | !.vscode/tasks.json 105 | !.vscode/launch.json 106 | !.vscode/extensions.json 107 | *.code-workspace 108 | 109 | # Local History for Visual Studio Code 110 | .history/ 111 | 112 | ### macOS template 113 | # General 114 | .DS_Store 115 | .AppleDouble 116 | .LSOverride 117 | 118 | # Icon must end with two \r 119 | Icon 120 | 121 | # Thumbnails 122 | ._* 123 | 124 | # Files that might appear in the root of a volume 125 | .DocumentRevisions-V100 126 | .fseventsd 127 | .Spotlight-V100 128 | .TemporaryItems 129 | .Trashes 130 | .VolumeIcon.icns 131 | .com.apple.timemachine.donotpresent 132 | 133 | # Directories potentially created on remote AFP share 134 | .AppleDB 135 | .AppleDesktop 136 | Network Trash Folder 137 | Temporary Items 138 | .apdisk 139 | 140 | ### Example user template template 141 | ### Example user template 142 | 143 | # IntelliJ project files 144 | .idea 145 | *.iml 146 | out 147 | gen 148 | .idea/ 149 | app.db 150 | migrations/ 151 | venv/ 152 | !/app/tests/.coverage 153 | .coverage 154 | __pycache__/ 155 | app/__pycache__/ 156 | app/auth/__pycache__/ 157 | app/errors/__pycache__/ 158 | app/main/__pycache__/ 159 | app/tests/.coverage 160 | app/tests/__pycache__/ 161 | /.env 162 | /.flaskenv 163 | 164 | # VSCode 165 | .vscode 166 | .venv 167 | 168 | # Logs 169 | logs/ 170 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Stefan van der Weide 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 | # Flask REST API Template 2 | A basic template to help kickstart development of a pure Flask API. This template is completely front-end independent 3 | and leaves all decisions up to the developer. The template includes basic login functionality based on JWT checks. 4 | How this token is stored and sent to the API is entirely up to the developer. 5 | 6 | ## Features 7 | * Minimal Flask 2.X App 8 | * Async/Await Functionallity 9 | * Unit tests 10 | * Basic Type Hints 11 | * Integration With Redis For Background Tasks 12 | * App Structured Using Blueprints 13 | * Application Factory Pattern Used 14 | * Authentication Functionality Using JWT 15 | * Basic Database Functionality Included (SQLite3) 16 | * Rate Limiting Functionality Based on Flask-Limiter For All The Routes In The Authentication Blueprint 17 | * Support for .env and .flaskenv files build in 18 | 19 | 20 | ### Application Structure 21 | 22 | The API is divided in six blueprints `auth`, `comments`, `errors`, `posts`, `tasks` and `users`. 23 | 24 | The `auth` blueprint is responsible for all the routes associated with user registration and authentication. 25 | 26 | The `comments` blueprint is responsible for handeling all requests related to comments, suchs as GET, POST and DELETE requests. 27 | 28 | The `errors` blueprint is used to catch all errors and return the correct information. 29 | 30 | The `posts` blueprint, just like the `comments` one, is responsible for handeling all requests related to posts, suchs as GET, POST and DELETE requests. 31 | 32 | The `tasks` blueprint is responsible for requests related to asynchronous background tasks. Such as launching, retrieving the status of a specific task and retrieving all completed tasks. 33 | 34 | The `users` blueprint handles the user related requests. Currently there are two routes which return either information ahout the current user or a different user based on username. 35 | 36 | ## Installation 37 | 38 | ##### Template and Dependencies 39 | 40 | * Clone this repository: 41 | 42 | ``` 43 | $ git clone https://github.com/stefanvdw1/flask-api-template.git 44 | ``` 45 | 46 | ### Virtual Environment Setup 47 | 48 | It is preferred to create a virtual environment per project, rather then installing all dependencies of each of your 49 | projects system wide. Once you install [virtual env](https://virtualenv.pypa.io/en/stable/installation/), and move to 50 | your projects directory through your terminal, you can set up a virtual env with: 51 | 52 | ```bash 53 | python3 -m venv .venv 54 | ``` 55 | 56 | ### Dependency installations 57 | 58 | To install the necessary packages: 59 | 60 | ```bash 61 | source venv/bin/activate 62 | pip3 install -r requirements.txt 63 | ``` 64 | 65 | This will install the required packages within your venv. 66 | 67 | --- 68 | 69 | ### Setting up a SQLite3 Database 70 | 71 | Database migrations are handled through Flask's Migrate Package, which provides a wrapper around Alembic. Migrations are done for updating and creating necessary tables/entries in your database. Flask provides a neat way of handling these. The files generate by the migrations should be added to source control. 72 | 73 | To setup a SQLite3 database for development (SQLite3 is **not** recommended for production, use something like PostgreSQL or MySQL) you navigate to the folder where `flask_api_template.py` is located and run: 74 | 75 | ```bash 76 | export FLASK_APP=flask_api_template.py 77 | ``` 78 | 79 | then you need to initiate your database and the migration folder with the following commands: 80 | 81 | ```bash 82 | flask db init 83 | ``` 84 | 85 | ```bash 86 | flask db migrate "Your message here" 87 | ``` 88 | 89 | ```bash 90 | flask db upgrade 91 | ``` 92 | 93 | ### Migrations 94 | 95 | To make changes to the database structure you can also use the `flask db` commands: 96 | 97 | ```bash 98 | export FLASK_APP=flask_api_template.py 99 | ``` 100 | 101 | ```bash 102 | flask db migrate -m "Your message here" 103 | ``` 104 | 105 | ```bash 106 | flask db upgrade 107 | ``` 108 | 109 | --- 110 | 111 | ## Running the Application 112 | 113 | Once you have setup your database, you are ready to run the application. 114 | Assuming that you have exported your app's path by: 115 | 116 | ```bash 117 | export FLASK_APP=flask_api_template.py 118 | ``` 119 | 120 | You can go ahead and run the application with a simple command: 121 | 122 | ```bash 123 | flask run 124 | ``` 125 | 126 | --- 127 | 128 | ## PostgreSQL 129 | TODO 130 | 131 | ## Gunicorn 132 | TODO 133 | 134 | ## Conclusion 135 | 136 | Hopefully this template will inspire you to use Flask for your future API projects. If you have any feedback please do let me know or feel free to fork and raise a PR. I'm actively trying to maintain this project so pull request are more than welcome. 137 | 138 | ### Todo's and Improvements 139 | 140 | - [x] Add request limiter 141 | - [x] Add support for enviroment variables 142 | - [x] Add async functionality 143 | - [X] Add marshmallow validation for payloads 144 | - [X] Add type hints 145 | - [X] Add redis support and background workers 146 | - [X] Add GitHub actions to run tests 147 | - [x] Add GitHub action to check requirements using Dependabot 148 | - [] Add tests for background tasks 149 | - [] Add instructions to deploy to a production 150 | 151 | 152 | ## Acknowledgements 153 | [Flask Mega Guide - Miguel Grinberg](https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-i-hello-world) 154 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | from config import Config 2 | from flask import Flask 3 | from flask_sqlalchemy import SQLAlchemy 4 | from flask_migrate import Migrate 5 | from flask_cors import CORS 6 | from flask_marshmallow import Marshmallow 7 | from flask_jwt_extended import JWTManager 8 | from flask_limiter import Limiter 9 | from flask_limiter.util import get_remote_address 10 | from redis import Redis 11 | import rq 12 | import logging 13 | from logging.handlers import RotatingFileHandler 14 | import os 15 | 16 | 17 | db = SQLAlchemy() 18 | migrate = Migrate() 19 | ma = Marshmallow() 20 | jwt = JWTManager() 21 | cors = CORS() 22 | limiter = Limiter( 23 | key_func=get_remote_address, default_limits=["200 per day", "50 per hour"] 24 | ) 25 | 26 | 27 | def create_app(config_class=Config): 28 | app = Flask(__name__) 29 | app.config.from_object(config_class) 30 | app.redis = Redis.from_url(app.config["REDIS_URL"]) 31 | app.task_queue = rq.Queue("flask-api-queue", connection=app.redis) 32 | 33 | with app.app_context(): 34 | db.init_app(app) 35 | 36 | # TODO: check if this is relevant for the template 37 | if db.engine.url.drivername == "sqlite": 38 | migrate.init_app(app, db, render_as_batch=True, compare_type=True) 39 | else: 40 | migrate.init_app(app, db, compare_type=True) 41 | 42 | ma.init_app(app) 43 | jwt.init_app(app) 44 | cors.init_app(app) 45 | limiter.init_app(app) 46 | 47 | from app.errors import bp as errors_bp 48 | from app.users import bp as users_bp 49 | from app.posts import bp as posts_bp 50 | from app.comments import bp as comments_bp 51 | from app.auth import bp as auth_bp 52 | from app.tasks import bp as tasks_bp 53 | 54 | app.register_blueprint(errors_bp) 55 | app.register_blueprint(users_bp, url_prefix="/api/users") 56 | app.register_blueprint(posts_bp, url_prefix="/api/posts") 57 | app.register_blueprint(comments_bp, url_prefix="/api/comments") 58 | app.register_blueprint(auth_bp, url_prefix="/api/auth") 59 | app.register_blueprint(tasks_bp, url_prefix="/api/tasks") 60 | 61 | # Set the rate limit for all routes in the auth_bp blueprint to 1 per second 62 | limiter.limit("60 per minute")(auth_bp) 63 | 64 | # Set the debuging to rotating log files and the log format and settings 65 | if not app.debug: 66 | if not os.path.exists("logs"): 67 | os.mkdir("logs") 68 | file_handler = RotatingFileHandler( 69 | "logs/flask_api.log", maxBytes=10240, backupCount=10 70 | ) 71 | file_handler.setFormatter( 72 | logging.Formatter( 73 | "%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]" 74 | ) 75 | ) 76 | file_handler.setLevel(logging.INFO) 77 | app.logger.addHandler(file_handler) 78 | 79 | app.logger.setLevel(logging.INFO) 80 | app.logger.info("Flask API startup") 81 | 82 | return app 83 | -------------------------------------------------------------------------------- /app/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp = Blueprint("auth", __name__) 4 | 5 | from app.auth import routes 6 | -------------------------------------------------------------------------------- /app/auth/routes.py: -------------------------------------------------------------------------------- 1 | from flask import Response, request, jsonify 2 | 3 | from app import db, jwt 4 | from app.auth import bp 5 | from app.models import Users, RevokedTokenModel 6 | from app.schemas import UsersDeserializingSchema 7 | from app.errors.handlers import bad_request, error_response 8 | 9 | from flask_jwt_extended import ( 10 | create_access_token, 11 | create_refresh_token, 12 | get_jwt_identity, 13 | jwt_required, 14 | get_jwt, 15 | ) 16 | 17 | from marshmallow import ValidationError 18 | 19 | user_schema = UsersDeserializingSchema() 20 | 21 | 22 | # Checks if the JWT is on the blacklisted token list 23 | @jwt.token_in_blocklist_loader 24 | def check_if_token_in_blacklist(jwt_header, jwt_data) -> bool: 25 | """ 26 | Helper function for checking if a token is present in the database 27 | revoked token table 28 | 29 | Parameters 30 | ---------- 31 | jwt_header : dictionary 32 | header data of the JWT 33 | jwt_data : dictionary 34 | payload data of the JWT 35 | 36 | Returns 37 | ------- 38 | bool 39 | Returns True if the token is revoked, False otherwise 40 | """ 41 | jti = jwt_data["jti"] 42 | return RevokedTokenModel.is_jti_blacklisted(jti) 43 | 44 | 45 | @bp.post("/register") 46 | def register() -> tuple[Response, int] | Response: 47 | """ 48 | Endpoint for adding a new user to the database 49 | 50 | Returns 51 | ------- 52 | str 53 | A JSON object containing the success message 54 | """ 55 | try: 56 | result = user_schema.load(request.get_json()) 57 | 58 | except ValidationError as e: 59 | return bad_request(e.messages) 60 | 61 | if Users.query.filter_by(username=result["username"]).first(): 62 | return bad_request("Username already in use") 63 | 64 | if Users.query.filter_by(email=result["email"]).first(): 65 | return bad_request("Email already in use") 66 | 67 | user: Users = Users( 68 | username=result["username"], 69 | first_name=result["first_name"], 70 | last_name=result["last_name"], 71 | email=result["email"], 72 | birthday=result["birthday"], 73 | ) 74 | 75 | user.set_password(result["password"]) 76 | 77 | db.session.add(user) 78 | db.session.commit() 79 | 80 | return jsonify({"msg": "Successfully registered"}), 201 81 | 82 | 83 | @bp.post("/login") 84 | def login() -> tuple[Response, int] | Response: 85 | """ 86 | Endpoint for authorizing a user and retrieving a JWT 87 | 88 | Returns 89 | ------- 90 | str 91 | A JSON object containing both the access JWT and the refresh JWT 92 | """ 93 | try: 94 | result = user_schema.load(request.get_json()) 95 | 96 | except ValidationError as e: 97 | return bad_request(e.messages) 98 | 99 | user = Users.query.filter_by(username=result["username"]).first() 100 | 101 | if user is None or not user.check_password(result["password"]): 102 | return error_response(401, message="Invalid username or password") 103 | 104 | tokens = { 105 | "access_token": create_access_token(identity=user.id, fresh=True), 106 | "refresh_token": create_refresh_token(identity=user.id), 107 | } 108 | 109 | return jsonify(tokens), 200 110 | 111 | 112 | @bp.post("/refresh") 113 | @jwt_required(refresh=True) 114 | def refresh() -> tuple[Response, int]: 115 | """ 116 | Endpoint in order to retrieve a new access JWT using the refresh JWT. 117 | A non-fresh access token is returned because the password is not involved in this transaction 118 | 119 | Returns 120 | ------- 121 | str 122 | A JSON object containing the new access token 123 | """ 124 | user_id = get_jwt_identity() 125 | new_token = create_access_token(identity=user_id, fresh=False) 126 | payload = {"access_token": new_token} 127 | 128 | return jsonify(payload), 200 129 | 130 | 131 | @bp.post("/fresh-login") 132 | def fresh_login() -> tuple[Response, int] | Response: 133 | """ 134 | Endpoint for requesting a new fresh access token 135 | 136 | Returns 137 | ------- 138 | str 139 | A JSON object containing 140 | """ 141 | try: 142 | result = user_schema.load(request.get_json()) 143 | 144 | except ValidationError as e: 145 | return bad_request(e.messages) 146 | 147 | user = Users.query.filter_by(username=result["username"]).first() 148 | 149 | if user is None or not user.check_password(result["password"]): 150 | return error_response(401, message="Invalid username or password") 151 | 152 | new_token = create_access_token(identity=user.id, fresh=True) 153 | payload = {"access_token": new_token} 154 | 155 | return jsonify(payload), 200 156 | 157 | 158 | @bp.delete("/logout/token") 159 | @jwt_required() 160 | def logout_access_token() -> tuple[Response, int]: 161 | """ 162 | Endpoint for revoking the current user"s access token 163 | 164 | Returns 165 | ------- 166 | str 167 | A JSON object containing the sucess message 168 | """ 169 | jti = get_jwt()["jti"] 170 | revoked_token = RevokedTokenModel(jti=jti) 171 | revoked_token.add() 172 | 173 | return jsonify({"msg": "Successfully logged out"}), 200 174 | 175 | 176 | @bp.delete("/logout/fresh") 177 | @jwt_required(refresh=True) 178 | def logout_refresh_token() -> tuple[Response, int]: 179 | """ 180 | Endpoint for revoking the current user"s refresh token 181 | 182 | Returns 183 | ------- 184 | str 185 | A JSON object containing a success message 186 | """ 187 | jti = get_jwt()["jti"] 188 | revoked_token = RevokedTokenModel(jti=jti) 189 | revoked_token.add() 190 | 191 | return jsonify({"msg": "Successfully logged out"}), 200 192 | -------------------------------------------------------------------------------- /app/comments/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp = Blueprint("comments", __name__) 4 | 5 | from app.comments import routes 6 | -------------------------------------------------------------------------------- /app/comments/routes.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from flask import request, jsonify, Response 3 | 4 | from app import db 5 | from app.comments import bp 6 | from app.models import Comments, Posts 7 | from app.schemas import CommentsSchema, CommentsDeserializingSchema 8 | from app.errors.handlers import bad_request 9 | 10 | from flask_jwt_extended import jwt_required, current_user 11 | 12 | from marshmallow import ValidationError 13 | 14 | import asyncio 15 | from aiohttp import ClientSession 16 | 17 | comment_schema = CommentsSchema() 18 | comments_schema = CommentsSchema(many=True) 19 | comment_deserializing_schema = CommentsDeserializingSchema() 20 | 21 | 22 | @bp.get("/get/user/comments/post/") 23 | @jwt_required() 24 | def get_comments_by_post_id(id: int) -> Response: 25 | """ 26 | Endpoint for retrieving the user comments associated with a particular post 27 | 28 | Parameters 29 | ---------- 30 | id : int 31 | ID of the post which comment's need to be retrieved 32 | 33 | Returns 34 | ------- 35 | str 36 | A JSON object containing the comments 37 | """ 38 | comments = Comments.query.filter_by(post_id=id).all() 39 | return comments_schema.jsonify(comments) 40 | 41 | 42 | @bp.post("/post/user/submit/comment") 43 | @jwt_required() 44 | def submit_comment() -> tuple[Response, int] | Response: 45 | """ 46 | Lets users submit a comment regarding a post 47 | 48 | Returns 49 | ------- 50 | str 51 | A JSON object containing a success message 52 | """ 53 | try: 54 | result = comment_deserializing_schema.load(request.get_json()) 55 | except ValidationError as e: 56 | return bad_request(e.messages) 57 | 58 | post = Posts.query.get(result["post_id"]) 59 | 60 | if not post: 61 | return bad_request("Post not found") 62 | 63 | if post.user_id != current_user.id: 64 | return bad_request("Unauthorized") 65 | 66 | comment = Comments(body=result["body"], post=post, user=current_user) 67 | 68 | db.session.add(comment) 69 | db.session.commit() 70 | 71 | return jsonify({"msg": "Comment succesfully submitted"}), 201 72 | 73 | 74 | @bp.delete("/delete/user/comment/") 75 | @jwt_required() 76 | def delete_comment(id: int) -> tuple[Response, int] | Response: 77 | """ 78 | Lets users delete one of their own comments 79 | 80 | Parameters 81 | ---------- 82 | id : int 83 | ID of the post which comment's need to be retrieved 84 | 85 | Returns 86 | ------- 87 | str 88 | A JSON object containing a success message 89 | """ 90 | comment = Comments.query.get(id) 91 | 92 | if not comment: 93 | return bad_request("Comment not found") 94 | 95 | if comment.user_id != current_user.id: 96 | return bad_request("Unauthorized") 97 | 98 | db.session.delete(comment) 99 | db.session.commit() 100 | 101 | return jsonify({"msg": "Comment succesfully deleted"}), 201 102 | 103 | 104 | @bp.get("/get/user/comments/async") 105 | @jwt_required() 106 | async def async_comments_api_call() -> dict[str, list[Any]]: 107 | """ 108 | Calls two endpoints from an external API as async demo 109 | 110 | Returns 111 | ------- 112 | str 113 | A JSON object containing the comment 114 | """ 115 | urls = [ 116 | "https://jsonplaceholder.typicode.com/comments", 117 | "https://jsonplaceholder.typicode.com/comments", 118 | "https://jsonplaceholder.typicode.com/comments", 119 | "https://jsonplaceholder.typicode.com/comments", 120 | "https://jsonplaceholder.typicode.com/comments", 121 | ] 122 | 123 | async with ClientSession() as session: 124 | tasks = (session.get(url) for url in urls) 125 | user_posts_res = await asyncio.gather(*tasks) 126 | json_res = [await r.json() for r in user_posts_res] 127 | 128 | response_data = {"comments": json_res} 129 | 130 | return response_data 131 | -------------------------------------------------------------------------------- /app/errors/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp = Blueprint("errors", __name__) 4 | 5 | from app.errors import handlers 6 | -------------------------------------------------------------------------------- /app/errors/handlers.py: -------------------------------------------------------------------------------- 1 | from flask import Response, jsonify 2 | from werkzeug.http import HTTP_STATUS_CODES 3 | 4 | 5 | def error_response(status_code: int, message=None) -> Response: 6 | """ 7 | A catch all function which returns the error code and message back to the user 8 | 9 | Parameters 10 | ---------- 11 | status_code : int 12 | The HTTP status code 13 | message : str, optional 14 | The error message, by default None 15 | 16 | Returns 17 | ------- 18 | str 19 | A JSON object containing the error information and HTTP code 20 | """ 21 | payload = {"error": HTTP_STATUS_CODES.get(status_code, "Unknown error")} 22 | 23 | if message: 24 | payload["msg"] = message 25 | 26 | response = jsonify(payload) 27 | response.status_code = status_code 28 | 29 | return response 30 | 31 | 32 | def bad_request(message: str) -> Response: 33 | """ 34 | Returns a 400 error code when a bad request has been made 35 | 36 | Parameters 37 | ---------- 38 | message : str 39 | The error message 40 | 41 | Returns 42 | ------- 43 | str 44 | A JSON object containing the error message and a 400 HTTP code 45 | """ 46 | return error_response(400, message) 47 | -------------------------------------------------------------------------------- /app/helpers/task_helpers.py: -------------------------------------------------------------------------------- 1 | from rq import get_current_job 2 | 3 | from app import db 4 | from app.models import Tasks 5 | 6 | 7 | def _set_task_progress(progress: int) -> None: 8 | """ 9 | A helper function which updates the progress status of a background task 10 | 11 | Parameters 12 | ---------- 13 | progress : int 14 | The percentage of the task progress 15 | """ 16 | job = get_current_job() 17 | if job: 18 | job.meta["progress"] = progress 19 | job.save_meta() 20 | 21 | if progress >= 100: 22 | task = Tasks.query.filter(task_id=job.get_id()).first() 23 | task.complete = True 24 | 25 | db.session.commit() 26 | -------------------------------------------------------------------------------- /app/helpers/test_helpers.py: -------------------------------------------------------------------------------- 1 | def register_and_login_test_user(c) -> str: 2 | """ 3 | Helper function that makes an HTTP request to register a test user 4 | 5 | Parameters 6 | ---------- 7 | c : object 8 | Test client object 9 | 10 | Returns 11 | ------- 12 | str 13 | Access JWT in order to use in subsequent tests 14 | """ 15 | c.post( 16 | "/api/auth/register", 17 | json={ 18 | "username": "test", 19 | "password": "secret", 20 | "first_name": "tim", 21 | "last_name": "apple", 22 | "email": "tim@test.com", 23 | "birthday": "1990-01-01", 24 | }, 25 | ) 26 | 27 | setup_resp = c.post( 28 | "/api/auth/login", json={"username": "test", "password": "secret"} 29 | ) 30 | setup_resp_json = setup_resp.get_json() 31 | setup_access_token = setup_resp_json["access_token"] 32 | 33 | return setup_access_token 34 | -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | from app import db, jwt 2 | from flask import current_app 3 | from werkzeug.security import generate_password_hash, check_password_hash 4 | from datetime import datetime 5 | import redis 6 | import rq 7 | 8 | 9 | @jwt.user_lookup_loader 10 | def user_loader_callback(jwt_header: dict, jwt_data: dict) -> object: 11 | """ 12 | HUser loader function which uses the JWT identity to retrieve a user object. 13 | Method is called on protected routes 14 | 15 | Parameters 16 | ---------- 17 | jwt_header : dictionary 18 | header data of the JWT 19 | jwt_data : dictionary 20 | payload data of the JWT 21 | 22 | Returns 23 | ------- 24 | object 25 | Returns a users object containing the user information 26 | """ 27 | return Users.query.filter_by(id=jwt_data["sub"]).first() 28 | 29 | 30 | # defines the Users database table 31 | class Users(db.Model): 32 | id = db.Column(db.Integer, primary_key=True) 33 | username = db.Column(db.String(64), unique=True, nullable=False) 34 | first_name = db.Column(db.String(50), nullable=False) 35 | last_name = db.Column(db.String(50), nullable=False) 36 | email = db.Column(db.String(120), unique=True, nullable=False) 37 | password_hash = db.Column(db.String(128), unique=False, nullable=False) 38 | birthday = db.Column(db.DateTime, nullable=False) 39 | join_date = db.Column(db.DateTime, default=datetime.utcnow) 40 | posts = db.relationship("Posts", backref="user", lazy="dynamic") 41 | comments = db.relationship("Comments", backref="user", lazy="dynamic") 42 | tasks = db.relationship("Tasks", backref="user", lazy="dynamic") 43 | 44 | def set_password(self, password: str): 45 | """ 46 | Helper function to generate the password hash of a user 47 | 48 | Parameters 49 | ---------- 50 | password : str 51 | The password provided by the user when registering 52 | """ 53 | self.password_hash = generate_password_hash(password) 54 | 55 | def check_password(self, password: str) -> bool: 56 | """ 57 | Helper function to verify the password hash agains the password provided 58 | by the user when logging in 59 | 60 | Parameters 61 | ---------- 62 | password : str 63 | The password provided by the user when logging in 64 | 65 | Returns 66 | ------- 67 | bool 68 | Returns True if the password is a match. If not False is returned 69 | """ 70 | return check_password_hash(self.password_hash, password) 71 | 72 | def launch_task(self, name: str, description: str, **kwargs) -> object: 73 | """ 74 | Helper function to launch a background task 75 | 76 | Parameters 77 | ---------- 78 | name : str 79 | Name of the task to launch 80 | description : str 81 | Description of the task to launch 82 | 83 | Returns 84 | ------- 85 | object 86 | A Tasks object containing the task information 87 | """ 88 | rq_job = current_app.task_queue.enqueue( 89 | "app.tasks.long_running_jobs" + name, **kwargs 90 | ) 91 | task = Tasks( 92 | task_id=rq_job.get_id(), name=name, description=description, user=self 93 | ) 94 | db.session.add(task) 95 | 96 | return task 97 | 98 | def get_tasks_in_progress(self) -> list: 99 | """ 100 | Helper function which retrieves the background tasks that are still in progress 101 | 102 | Returns 103 | ------- 104 | list 105 | A list of Tasks objects 106 | """ 107 | return Tasks.query.filter_by(user=self, complete=False).all() 108 | 109 | def get_task_in_progress(self, name: str) -> object: 110 | """ 111 | Helper function to retrieve a task in progress based on name 112 | 113 | Parameters 114 | ---------- 115 | name : str 116 | name of the task to be retrieved 117 | 118 | Returns 119 | ------- 120 | object 121 | A task object 122 | """ 123 | return Tasks.query.filter_by(name=name, user=self, complete=False).first() 124 | 125 | def get_completed_tasks(self) -> dict: 126 | """ 127 | Helper function to retrieve all completed tasks 128 | 129 | Returns 130 | ------- 131 | dict 132 | A dictionary of Tasks objects 133 | """ 134 | return Tasks.query.filter_by(user=self, complete=True).all() 135 | 136 | 137 | class Posts(db.Model): 138 | id = db.Column(db.Integer, primary_key=True) 139 | body = db.Column(db.String(140)) 140 | timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow) 141 | comments = db.relationship("Comments", backref="post", lazy="dynamic") 142 | user_id = db.Column(db.Integer, db.ForeignKey("users.id")) 143 | 144 | 145 | class Comments(db.Model): 146 | id = db.Column(db.Integer, primary_key=True) 147 | body = db.Column(db.String(140)) 148 | timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow) 149 | post_id = db.Column(db.Integer, db.ForeignKey("posts.id")) 150 | user_id = db.Column(db.Integer, db.ForeignKey("users.id")) 151 | 152 | 153 | class RevokedTokenModel(db.Model): 154 | id = db.Column(db.Integer, primary_key=True) 155 | jti = db.Column(db.String(120)) 156 | date_revoked = db.Column(db.DateTime, default=datetime.utcnow) 157 | 158 | def add(self): 159 | """ 160 | Helper function to add a JWT to the table 161 | """ 162 | db.session.add(self) 163 | db.session.commit() 164 | 165 | @classmethod 166 | def is_jti_blacklisted(cls, jti: str) -> bool: 167 | """ 168 | Helper function to check if a JWT is in the Revoked Token table 169 | 170 | Parameters 171 | ---------- 172 | jti : str 173 | The JWT unique identifier 174 | 175 | Returns 176 | ------- 177 | bool 178 | Return True if the JWT is in the Revoked Token table 179 | """ 180 | query = cls.query.filter_by(jti=jti).first() 181 | return bool(query) 182 | 183 | 184 | class Tasks(db.Model): 185 | id = db.Column(db.Integer, primary_key=True) 186 | task_id = db.Column(db.String(36), index=True) 187 | name = db.Column(db.String(128), index=True) 188 | description = db.Column(db.String(128)) 189 | user_id = db.Column(db.Integer, db.ForeignKey("users.id")) 190 | complete = db.Column(db.Boolean, default=False) 191 | 192 | def get_rq_job(self): 193 | try: 194 | rq_job = rq.job.Job.fetch(self.task_id, connection=current_app.redis) 195 | 196 | except (redis.exceptions.RedisError, rq.exceptions.NoSuchJobError): 197 | return None 198 | 199 | return rq_job 200 | 201 | def get_progress(self): 202 | job = self.get_rq_job() 203 | return job.meta.get("progress", 0) if job is not None else 100 204 | -------------------------------------------------------------------------------- /app/posts/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp = Blueprint("posts", __name__) 4 | 5 | from app.posts import routes 6 | -------------------------------------------------------------------------------- /app/posts/routes.py: -------------------------------------------------------------------------------- 1 | from flask import request, jsonify, Response 2 | 3 | from app import db 4 | from app.posts import bp 5 | from app.models import Posts 6 | from app.schemas import PostsSchema 7 | from app.errors.handlers import bad_request 8 | 9 | from flask_jwt_extended import jwt_required, current_user 10 | 11 | from marshmallow import ValidationError 12 | 13 | import asyncio 14 | from aiohttp import ClientSession 15 | 16 | # Declare database schemas so they can be returned as JSON objects 17 | post_schema = PostsSchema() 18 | posts_schema = PostsSchema(many=True) 19 | 20 | 21 | @bp.get("get/user/posts") 22 | @jwt_required() 23 | def get_posts() -> tuple[Response, int]: 24 | """ 25 | Returns all posts submitted by the user making the request 26 | 27 | Returns 28 | ------- 29 | JSON 30 | A JSON object containing all post data 31 | """ 32 | posts = current_user.posts.all() 33 | 34 | return posts_schema.jsonify(posts), 200 35 | 36 | 37 | @bp.get("/get/user/post/") 38 | @jwt_required() 39 | def get_post_by_id(id: int) -> tuple[Response, int] | Response: 40 | """ 41 | Returns a specific post based on the ID in the URL 42 | 43 | Parameters 44 | ---------- 45 | id : int 46 | The ID of the post 47 | 48 | Returns 49 | ------- 50 | JSON 51 | A JSON object containing all post data 52 | """ 53 | post = Posts.query.get(id) 54 | 55 | if not post: 56 | return bad_request("No post found") 57 | 58 | return post_schema.jsonify(post), 200 59 | 60 | 61 | @bp.post("/post/user/submit/post") 62 | @jwt_required() 63 | def submit_post() -> tuple[Response, int] | Response: 64 | """ 65 | Lets users retrieve a user profile when logged in 66 | 67 | Returns 68 | ------- 69 | str 70 | A JSON object containing a success message 71 | """ 72 | try: 73 | result = post_schema.load(request.json) 74 | except ValidationError as e: 75 | return bad_request(e.messages[0]) 76 | 77 | post = Posts(body=result["body"], user=current_user) 78 | 79 | db.session.add(post) 80 | db.session.commit() 81 | 82 | return jsonify({"msg": "Post succesfully submitted"}), 201 83 | 84 | 85 | @bp.delete("/delete/user/post/") 86 | @jwt_required() 87 | def delete_post(id: int) -> tuple[Response, int] | Response: 88 | """ 89 | Lets users retrieve a user profile when logged in 90 | 91 | Parameters 92 | ---------- 93 | id : int 94 | The ID of the post to be deleted 95 | 96 | Returns 97 | ------- 98 | str 99 | A JSON object containing the success message 100 | """ 101 | post = Posts.query.get(id) 102 | 103 | if not post: 104 | return bad_request("Post not found") 105 | 106 | if post.user_id != current_user.id: 107 | return bad_request("Unauthorized") 108 | 109 | db.session.delete(post) 110 | db.session.commit() 111 | 112 | return jsonify({"msg": "Post succesfully deleted"}), 201 113 | 114 | 115 | @bp.get("/get/user/posts/async") 116 | @jwt_required() 117 | async def async_posts_api_call() -> tuple[dict, int]: 118 | """ 119 | Calls two endpoints from an external API as async demo 120 | 121 | Returns 122 | ------- 123 | str 124 | A JSON object containing the posts 125 | """ 126 | urls = [ 127 | "https://jsonplaceholder.typicode.com/posts", 128 | "https://jsonplaceholder.typicode.com/posts", 129 | "https://jsonplaceholder.typicode.com/posts", 130 | "https://jsonplaceholder.typicode.com/posts", 131 | "https://jsonplaceholder.typicode.com/posts", 132 | ] 133 | 134 | async with ClientSession() as session: 135 | tasks = (session.get(url) for url in urls) 136 | user_posts_res = await asyncio.gather(*tasks) 137 | json_res = [await r.json() for r in user_posts_res] 138 | 139 | response_data = {"posts": json_res} 140 | 141 | return response_data, 200 142 | -------------------------------------------------------------------------------- /app/schemas.py: -------------------------------------------------------------------------------- 1 | from app import ma 2 | from app.models import Users, Posts, Comments, Tasks 3 | 4 | from marshmallow import Schema, fields 5 | 6 | 7 | class UsersSchema(ma.SQLAlchemyAutoSchema): 8 | class Meta: 9 | model = Users 10 | 11 | 12 | class UsersDeserializingSchema(Schema): 13 | username = fields.String() 14 | password = fields.String() 15 | first_name = fields.String() 16 | last_name = fields.String() 17 | email = fields.Email() 18 | birthday = fields.Date() 19 | 20 | 21 | class PostsSchema(ma.SQLAlchemyAutoSchema): 22 | class Meta: 23 | model = Posts 24 | 25 | 26 | class PostsDeserializingSchema(Schema): 27 | body = fields.String() 28 | 29 | 30 | class CommentsSchema(ma.SQLAlchemyAutoSchema): 31 | class Meta: 32 | model = Comments 33 | 34 | 35 | class CommentsDeserializingSchema(Schema): 36 | body = fields.String() 37 | post_id = fields.Integer() 38 | 39 | 40 | class TasksSchema(ma.SQLAlchemyAutoSchema): 41 | class Meta: 42 | model = Tasks 43 | -------------------------------------------------------------------------------- /app/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp = Blueprint("tasks", __name__) 4 | 5 | from app.tasks import routes 6 | -------------------------------------------------------------------------------- /app/tasks/long_running_jobs.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from datetime import time 3 | 4 | from app import create_app 5 | from app.helpers.task_helpers import _set_task_progress 6 | 7 | # Create the app in order to operate within the context of the app 8 | app = create_app() 9 | 10 | 11 | def count_seconds(**kwargs: int) -> None: 12 | """ 13 | A background task which counts up to the number of seconds passed as an argument 14 | """ 15 | with app.app_context(): 16 | try: 17 | number: int | None = kwargs.get("number") 18 | 19 | if number: 20 | _set_task_progress(0) 21 | 22 | i = 0 23 | 24 | for i in range(0, number): 25 | i += 1 26 | time.sleep(1) 27 | _set_task_progress(100 * i // number) 28 | 29 | # TODO: Make this a specific except type, no bare except 30 | except: 31 | app.logger.error("Unhandled exception", exc_info=sys.exc_info()) 32 | 33 | finally: 34 | _set_task_progress(100) 35 | -------------------------------------------------------------------------------- /app/tasks/routes.py: -------------------------------------------------------------------------------- 1 | from flask import Response, jsonify 2 | from flask_jwt_extended import current_user, jwt_required 3 | 4 | from app import db 5 | from app.errors.handlers import bad_request 6 | from app.schemas import TasksSchema 7 | from app.tasks import bp 8 | 9 | tasks_schema = TasksSchema(many=True) 10 | 11 | 12 | @bp.get("/background-task/count-seconds/") 13 | @jwt_required() 14 | def background_worker_count_seconds(number: int) -> tuple[Response, int] | Response: 15 | """ 16 | Spawn a background task via RQ to perform a long running task 17 | 18 | Parameters 19 | ---------- 20 | number : int 21 | The number of seconds the background tasks needs to count 22 | 23 | Returns 24 | ------- 25 | JSON 26 | A JSON object containing either the success message or an error message 27 | """ 28 | if current_user.get_task_in_progress("count_seconds"): 29 | return bad_request("Task already in progress") 30 | 31 | else: 32 | current_user.launch_task("count_seconds", "Counting seconds...", number=number) 33 | db.session.commit() 34 | 35 | return jsonify({"msg": "Launched background task"}), 200 36 | 37 | 38 | @bp.get("/get/active-background-tasks") 39 | @jwt_required() 40 | def active_background_tasks() -> tuple[Response, int] | str: 41 | """ 42 | Endpoint to retrieve all the active background tasks 43 | 44 | Returns 45 | ------- 46 | str 47 | A JSON object containing the active tasks 48 | """ 49 | tasks = current_user.get_tasks_in_progress() 50 | return tasks_schema.jsonify(tasks), 200 51 | 52 | 53 | @bp.get("/get/finished-background-tasks") 54 | @jwt_required() 55 | def finished_background_tasks() -> tuple[Response, int] | str: 56 | """ 57 | Endpoint to retrieve the finished background tasks 58 | 59 | Returns 60 | ------- 61 | str 62 | A JSON object containing the finished tasks 63 | """ 64 | tasks = current_user.get_completed_tasks() 65 | return tasks_schema.jsonify(tasks), 200 66 | -------------------------------------------------------------------------------- /app/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanVDWeide/flask-api-template/fbd0a5db4286bf2944dfcf149263d5efad8c930a/app/tests/__init__.py -------------------------------------------------------------------------------- /app/tests/test_auth.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from app import create_app, db 3 | from app.models import Users 4 | from config import Config 5 | 6 | 7 | class TestConfig(Config): 8 | TESTING = True 9 | SQLALCHEMY_DATABASE_URI = "sqlite:///" 10 | SECRET_KEY = "SQL-SECRET" 11 | JWT_SECRET_KEY = "JWT-SECRET" 12 | 13 | 14 | class TestAuth(unittest.TestCase): 15 | def setUp(self): 16 | self.app = create_app(TestConfig) 17 | self.app_context = self.app.app_context() 18 | self.app_context.push() 19 | db.create_all() 20 | 21 | def tearDown(self): 22 | db.session.remove() 23 | db.drop_all() 24 | self.app_context.pop() 25 | 26 | def test_password_hashing(self): 27 | u = Users(username="stefan") 28 | u.set_password("dog") 29 | 30 | self.assertFalse(u.check_password("cat")) 31 | self.assertTrue(u.check_password("dog")) 32 | 33 | def test_register(self): 34 | with self.app.test_client() as c: 35 | resp = c.post( 36 | "/api/auth/register", 37 | json={ 38 | "username": "test", 39 | "password": "secret", 40 | "first_name": "tim", 41 | "last_name": "apple", 42 | "email": "tim@test.com", 43 | "birthday": "1990-01-01", 44 | }, 45 | ) 46 | 47 | json_data = resp.get_json() 48 | 49 | self.assertEqual(201, resp.status_code, msg=json_data) 50 | self.assertEqual("Successfully registered", json_data["msg"]) 51 | 52 | def test_login(self): 53 | with self.app.test_client() as c: 54 | c.post( 55 | "/api/auth/register", 56 | json={ 57 | "username": "test", 58 | "password": "secret", 59 | "first_name": "tim", 60 | "last_name": "apple", 61 | "email": "tim@test.com", 62 | "birthday": "1990-01-01", 63 | }, 64 | ) 65 | 66 | resp = c.post( 67 | "/api/auth/login", json={"username": "test", "password": "secret"} 68 | ) 69 | json_data = resp.get_json() 70 | 71 | self.assertEqual(200, resp.status_code, msg=json_data) 72 | self.assertTrue(json_data["access_token"]) 73 | self.assertTrue(json_data["refresh_token"]) 74 | 75 | def test_refresh(self): 76 | with self.app.test_client() as c: 77 | c.post( 78 | "/api/auth/register", 79 | json={ 80 | "username": "test", 81 | "password": "secret", 82 | "first_name": "tim", 83 | "last_name": "apple", 84 | "email": "tim@test.com", 85 | "birthday": "1990-01-01", 86 | }, 87 | ) 88 | 89 | setup_resp = c.post( 90 | "/api/auth/login", json={"username": "test", "password": "secret"} 91 | ) 92 | setup_resp_json = setup_resp.get_json() 93 | setup_refresh_token = setup_resp_json["refresh_token"] 94 | setup_access_token = setup_resp_json["access_token"] 95 | 96 | resp = c.post( 97 | "/api/auth/refresh", 98 | headers={"Authorization": "Bearer {}".format(setup_refresh_token)}, 99 | ) 100 | 101 | json_data = resp.get_json() 102 | new_access_token = json_data["access_token"] 103 | 104 | self.assertEqual(200, resp.status_code, msg=json_data) 105 | self.assertNotEqual(setup_access_token, new_access_token) 106 | 107 | def test_fresh_login(self): 108 | with self.app.test_client() as c: 109 | c.post( 110 | "/api/auth/register", 111 | json={ 112 | "username": "test", 113 | "password": "secret", 114 | "first_name": "tim", 115 | "last_name": "apple", 116 | "email": "tim@test.com", 117 | "birthday": "1990-01-01", 118 | }, 119 | ) 120 | 121 | resp = c.post( 122 | "/api/auth/fresh-login", json={"username": "test", "password": "secret"} 123 | ) 124 | json_data = resp.get_json() 125 | 126 | self.assertEqual(200, resp.status_code, msg=json_data) 127 | self.assertTrue(json_data["access_token"]) 128 | 129 | def test_logout_access_token(self): 130 | with self.app.test_client() as c: 131 | c.post( 132 | "/api/auth/register", 133 | json={ 134 | "username": "test", 135 | "password": "secret", 136 | "first_name": "tim", 137 | "last_name": "apple", 138 | "email": "tim@test.com", 139 | "birthday": "1990-01-01", 140 | }, 141 | ) 142 | 143 | setup_resp = c.post( 144 | "/api/auth/login", json={"username": "test", "password": "secret"} 145 | ) 146 | setup_resp_json = setup_resp.get_json() 147 | setup_access_token = setup_resp_json["access_token"] 148 | 149 | resp = c.delete( 150 | "/api/auth/logout/token", 151 | headers={"Authorization": "Bearer {}".format(setup_access_token)}, 152 | ) 153 | json_data = resp.get_json() 154 | msg = json_data["msg"] 155 | 156 | self.assertEqual(200, resp.status_code, msg=json_data) 157 | self.assertEqual("Successfully logged out", msg) 158 | 159 | def test_logout_refresh_token(self): 160 | with self.app.test_client() as c: 161 | c.post( 162 | "/api/auth/register", 163 | json={ 164 | "username": "test", 165 | "password": "secret", 166 | "first_name": "tim", 167 | "last_name": "apple", 168 | "email": "tim@test.com", 169 | "birthday": "1990-01-01", 170 | }, 171 | ) 172 | 173 | setup_resp = c.post( 174 | "/api/auth/login", json={"username": "test", "password": "secret"} 175 | ) 176 | setup_resp_json = setup_resp.get_json() 177 | setup_refresh_token = setup_resp_json["refresh_token"] 178 | 179 | resp = c.delete( 180 | "/api/auth/logout/fresh", 181 | headers={"Authorization": "Bearer {}".format(setup_refresh_token)}, 182 | ) 183 | json_data = resp.get_json() 184 | msg = json_data["msg"] 185 | 186 | self.assertEqual(200, resp.status_code, msg=json_data) 187 | self.assertEqual("Successfully logged out", msg) 188 | 189 | 190 | if __name__ == "__main__": 191 | unittest.main() 192 | -------------------------------------------------------------------------------- /app/tests/test_comments.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from flask import json 4 | from app import create_app, db 5 | from app.helpers.test_helpers import register_and_login_test_user 6 | from config import Config 7 | 8 | 9 | class TestConfig(Config): 10 | TESTING = True 11 | SQLALCHEMY_DATABASE_URI = "sqlite:///" 12 | SECRET_KEY = "SQL-SECRET" 13 | JWT_SECRET_KEY = "JWT-SECRET" 14 | 15 | 16 | class TestComments(unittest.TestCase): 17 | def setUp(self): 18 | self.app = create_app(TestConfig) 19 | self.app_context = self.app.app_context() 20 | self.app_context.push() 21 | db.create_all() 22 | 23 | def tearDown(self): 24 | db.session.remove() 25 | db.drop_all() 26 | self.app_context.pop() 27 | 28 | def test_get_user_comment(self): 29 | with self.app.test_client() as c: 30 | setup_access_token = register_and_login_test_user(c) 31 | 32 | post_payload = {"body": "This is a test post"} 33 | 34 | resp = c.post( 35 | "/api/posts/post/user/submit/post", 36 | headers={"Authorization": "Bearer {}".format(setup_access_token)}, 37 | json=post_payload, 38 | ) 39 | 40 | comment_payload = {"body": "This is a test comment", "post_id": 1} 41 | 42 | c.post( 43 | "/api/comments/post/user/submit/comment", 44 | headers={"Authorization": "Bearer {}".format(setup_access_token)}, 45 | json=comment_payload, 46 | ) 47 | 48 | resp = c.get( 49 | "/api/comments/get/user/comments/post/1", 50 | headers={"Authorization": "Bearer {}".format(setup_access_token)}, 51 | ) 52 | 53 | json_data = resp.get_json() 54 | 55 | self.assertEqual(200, resp.status_code, msg=json_data) 56 | 57 | def test_submit_user_comment(self): 58 | with self.app.test_client() as c: 59 | setup_access_token = register_and_login_test_user(c) 60 | 61 | post_payload = {"body": "This is a test post"} 62 | 63 | resp = c.post( 64 | "/api/posts/post/user/submit/post", 65 | headers={"Authorization": "Bearer {}".format(setup_access_token)}, 66 | json=post_payload, 67 | ) 68 | 69 | payload = {"body": "This is a test comment", "post_id": 1} 70 | 71 | resp = c.post( 72 | "/api/comments/post/user/submit/comment", 73 | headers={"Authorization": "Bearer {}".format(setup_access_token)}, 74 | json=payload, 75 | ) 76 | 77 | json_data = resp.get_json() 78 | 79 | self.assertEqual(201, resp.status_code, msg=json_data) 80 | 81 | def test_delete_user_comment(self): 82 | with self.app.test_client() as c: 83 | setup_access_token = register_and_login_test_user(c) 84 | 85 | post_payload = {"body": "This is a test comment"} 86 | 87 | resp = c.post( 88 | "/api/posts/post/user/submit/post", 89 | headers={"Authorization": "Bearer {}".format(setup_access_token)}, 90 | json=post_payload, 91 | ) 92 | 93 | payload = {"body": "This is a test comment", "post_id": 1} 94 | 95 | resp = c.post( 96 | "/api/comments/post/user/submit/comment", 97 | headers={"Authorization": "Bearer {}".format(setup_access_token)}, 98 | json=payload, 99 | ) 100 | 101 | resp = c.delete( 102 | "/api/comments/delete/user/comment/1", 103 | headers={"Authorization": "Bearer {}".format(setup_access_token)}, 104 | ) 105 | 106 | json_data = resp.get_json() 107 | 108 | self.assertEqual(201, resp.status_code, msg=json_data) 109 | 110 | def test_get_user_comments_async(self): 111 | with self.app.test_client() as c: 112 | setup_access_token = register_and_login_test_user(c) 113 | 114 | resp = c.get( 115 | "/api/comments/get/user/comments/async", 116 | headers={"Authorization": "Bearer {}".format(setup_access_token)}, 117 | ) 118 | 119 | json_data = resp.get_json() 120 | 121 | self.assertEqual(200, resp.status_code, msg=json_data) 122 | 123 | 124 | if __name__ == "__main__": 125 | unittest.main() 126 | -------------------------------------------------------------------------------- /app/tests/test_errors.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from app import create_app, db 3 | from app.models import Users 4 | from config import Config 5 | 6 | 7 | class TestConfig(Config): 8 | TESTING = True 9 | SQLALCHEMY_DATABASE_URI = "sqlite:///" 10 | SECRET_KEY = "SQL-SECRET" 11 | JWT_SECRET_KEY = "JWT-SECRET" 12 | 13 | 14 | class TestAuth(unittest.TestCase): 15 | def setUp(self): 16 | self.app = create_app(TestConfig) 17 | self.app_context = self.app.app_context() 18 | self.app_context.push() 19 | 20 | def tearDown(self): 21 | self.app_context.pop() 22 | 23 | def test_400_error(self): 24 | with self.app.test_client() as c: 25 | # Make a post request to the register route without JSON data to trigger 400 error 26 | resp = c.post( 27 | "/api/auth/register", 28 | json={ 29 | "username": 1, 30 | "password": "secret", 31 | "first_name": "tim", 32 | "last_name": "apple", 33 | "email": "tim@test.com", 34 | }, 35 | ) 36 | 37 | json_data = resp.get_json() 38 | 39 | self.assertEqual(400, resp.status_code, msg=json_data) 40 | self.assertEqual({"username": ["Not a valid string."]}, json_data["msg"]) 41 | 42 | def test_other_errors(self): 43 | with self.app.test_client() as c: 44 | # Make a post request to a nonexistent route to trigger 404 error 45 | resp = c.post("/api/auth/xxx") 46 | 47 | json_data = resp.get_json() 48 | 49 | self.assertEqual(404, resp.status_code, msg=json_data) 50 | 51 | 52 | if __name__ == "__main__": 53 | unittest.main() 54 | -------------------------------------------------------------------------------- /app/tests/test_posts.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from app import create_app, db 3 | from app.helpers.test_helpers import register_and_login_test_user 4 | from config import Config 5 | 6 | 7 | class TestConfig(Config): 8 | TESTING = True 9 | SQLALCHEMY_DATABASE_URI = "sqlite:///" 10 | SECRET_KEY = "SQL-SECRET" 11 | JWT_SECRET_KEY = "JWT-SECRET" 12 | 13 | 14 | class TestPosts(unittest.TestCase): 15 | def setUp(self): 16 | self.app = create_app(TestConfig) 17 | self.app_context = self.app.app_context() 18 | self.app_context.push() 19 | db.create_all() 20 | 21 | def tearDown(self): 22 | db.session.remove() 23 | db.drop_all() 24 | self.app_context.pop() 25 | 26 | def test_get_user_posts(self): 27 | with self.app.test_client() as c: 28 | setup_access_token = register_and_login_test_user(c) 29 | 30 | payload = {"body": "This is a test post"} 31 | 32 | resp = c.post( 33 | "/api/posts/post/user/submit/post", 34 | headers={"Authorization": "Bearer {}".format(setup_access_token)}, 35 | json=payload, 36 | ) 37 | 38 | resp = c.get( 39 | "api/posts/get/user/posts", 40 | headers={"Authorization": "Bearer {}".format(setup_access_token)}, 41 | ) 42 | 43 | json_data = resp.get_json() 44 | 45 | self.assertEqual(200, resp.status_code, msg=json_data) 46 | 47 | def test_get_user_post_by_id(self): 48 | with self.app.test_client() as c: 49 | setup_access_token = register_and_login_test_user(c) 50 | 51 | payload = {"body": "This is a test post"} 52 | 53 | c.post( 54 | "/api/posts/post/user/submit/post", 55 | headers={"Authorization": "Bearer {}".format(setup_access_token)}, 56 | json=payload, 57 | ) 58 | 59 | resp = c.get( 60 | "/api/posts/get/user/post/1", 61 | headers={"Authorization": "Bearer {}".format(setup_access_token)}, 62 | ) 63 | 64 | json_data = resp.get_json() 65 | 66 | self.assertEqual(200, resp.status_code, msg=json_data) 67 | 68 | def test_submit_user_post(self): 69 | with self.app.test_client() as c: 70 | setup_access_token = register_and_login_test_user(c) 71 | 72 | payload = {"body": "This is a test post"} 73 | 74 | resp = c.post( 75 | "/api/posts/post/user/submit/post", 76 | headers={"Authorization": "Bearer {}".format(setup_access_token)}, 77 | json=payload, 78 | ) 79 | 80 | json_data = resp.get_json() 81 | 82 | self.assertEqual(201, resp.status_code, msg=json_data) 83 | 84 | def test_delete_user_post(self): 85 | with self.app.test_client() as c: 86 | setup_access_token = register_and_login_test_user(c) 87 | 88 | payload = {"body": "This is a test post"} 89 | 90 | resp = c.post( 91 | "/api/posts/post/user/submit/post", 92 | headers={"Authorization": "Bearer {}".format(setup_access_token)}, 93 | json=payload, 94 | ) 95 | 96 | resp = c.delete( 97 | "/api/posts/delete/user/post/1", 98 | headers={"Authorization": "Bearer {}".format(setup_access_token)}, 99 | ) 100 | 101 | json_data = resp.get_json() 102 | 103 | self.assertEqual(201, resp.status_code, msg=json_data) 104 | 105 | def test_get_user_posts_async(self): 106 | with self.app.test_client() as c: 107 | setup_access_token = register_and_login_test_user(c) 108 | 109 | resp = c.get( 110 | "/api/posts/get/user/posts/async", 111 | headers={"Authorization": "Bearer {}".format(setup_access_token)}, 112 | ) 113 | 114 | json_data = resp.get_json() 115 | 116 | self.assertEqual(200, resp.status_code, msg=json_data) 117 | 118 | 119 | if __name__ == "__main__": 120 | unittest.main() 121 | -------------------------------------------------------------------------------- /app/tests/test_users.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from app import create_app, db 3 | from app.helpers.test_helpers import register_and_login_test_user 4 | from config import Config 5 | 6 | 7 | class TestConfig(Config): 8 | TESTING = True 9 | SQLALCHEMY_DATABASE_URI = "sqlite:///" 10 | SECRET_KEY = "SQL-SECRET" 11 | JWT_SECRET_KEY = "JWT-SECRET" 12 | 13 | 14 | class TestUsers(unittest.TestCase): 15 | def setUp(self): 16 | self.app = create_app(TestConfig) 17 | self.app_context = self.app.app_context() 18 | self.app_context.push() 19 | db.create_all() 20 | 21 | def tearDown(self): 22 | db.session.remove() 23 | db.drop_all() 24 | self.app_context.pop() 25 | 26 | def test_user_page(self): 27 | with self.app.test_client() as c: 28 | setup_access_token = register_and_login_test_user(c) 29 | 30 | resp = c.get( 31 | "/api/users/get/user/profile", 32 | headers={"Authorization": "Bearer {}".format(setup_access_token)}, 33 | ) 34 | 35 | json_data = resp.get_json() 36 | 37 | self.assertEqual(200, resp.status_code, msg=json_data) 38 | 39 | def test_get_user(self): 40 | with self.app.test_client() as c: 41 | setup_access_token = register_and_login_test_user(c) 42 | 43 | resp = c.get( 44 | "/api/users/get/user/profile/test", 45 | headers={"Authorization": "Bearer {}".format(setup_access_token)}, 46 | ) 47 | 48 | json_data = resp.get_json() 49 | 50 | self.assertEqual(200, resp.status_code, msg=json_data) 51 | 52 | 53 | if __name__ == "__main__": 54 | unittest.main() 55 | -------------------------------------------------------------------------------- /app/users/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp = Blueprint("users", __name__) 4 | 5 | from app.users import routes 6 | -------------------------------------------------------------------------------- /app/users/routes.py: -------------------------------------------------------------------------------- 1 | from flask import Response 2 | from flask_jwt_extended import current_user, jwt_required 3 | 4 | from app.errors.handlers import bad_request 5 | from app.models import Users 6 | from app.schemas import UsersSchema 7 | from app.users import bp 8 | 9 | # Declare database schemas so they can be returned as JSON objects 10 | user_schema = UsersSchema(exclude=("email", "password_hash")) 11 | users_schema = UsersSchema(many=True, exclude=("email", "password_hash")) 12 | 13 | 14 | @bp.get("/get/user/profile") 15 | @jwt_required() 16 | def user_page() -> tuple[Response, int] | str: 17 | """ 18 | Let's users retrieve their own user information when logged in 19 | 20 | Returns 21 | ------- 22 | str 23 | A JSON object containing the user profile information 24 | """ 25 | return user_schema.jsonify(current_user), 200 26 | 27 | 28 | @bp.get("/get/user/profile/") 29 | @jwt_required() 30 | def get_user(username: str) -> tuple[Response, int] | Response: 31 | """ 32 | Lets users retrieve a user profile when logged in 33 | 34 | Parameters 35 | ---------- 36 | username : str 37 | The username of the user who's information should be retrieved 38 | 39 | Returns 40 | ------- 41 | str 42 | A JSON object containing the user profile information 43 | """ 44 | user = Users.query.filter_by(username=username).first() 45 | 46 | if user is None: 47 | return bad_request("User not found") 48 | 49 | return user_schema.jsonify(user), 200 50 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | 4 | # Set base directory of the app 5 | basedir = os.path.abspath(os.path.dirname(__file__)) 6 | 7 | # Load the .env and .flaskenv variables 8 | load_dotenv(os.path.join(basedir, ".env")) 9 | 10 | 11 | class Config(object): 12 | """ 13 | Set the config variables for the Flask app 14 | 15 | """ 16 | 17 | SECRET_KEY = os.environ.get("SECRET_KEY") 18 | 19 | SQLALCHEMY_DATABASE_URI = os.environ.get( 20 | "DATABASE_URL" 21 | ) or "sqlite:///" + os.path.join(basedir, "app.db") 22 | SQLALCHEMY_TRACK_MODIFICATIONS = False 23 | 24 | JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY") 25 | JWT_BLACKLIST_ENABLED = True 26 | JWT_BLACKLIST_TOKEN_CHECKS = ["access", "refresh"] 27 | 28 | REDIS_URL = os.environ.get("REDIS_URL") or "redis://" 29 | -------------------------------------------------------------------------------- /flask_api_template.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from dateutil.relativedelta import relativedelta 4 | 5 | from app import create_app, db 6 | 7 | app = create_app() 8 | 9 | 10 | @app.cli.command() 11 | def remove_old_jwts(): 12 | """ 13 | Scan the database for JWT tokens in the Revoked Token table older than 5 days 14 | and remove them. 15 | """ 16 | 17 | # Import within the function to prevent working outside of application context 18 | # when calling flask --help 19 | from app.models import RevokedTokenModel 20 | 21 | delete_date = datetime.utcnow() - relativedelta(days=5) 22 | 23 | old_tokens = ( 24 | db.session.query(RevokedTokenModel) 25 | .filter(RevokedTokenModel.date_revoked < delete_date) 26 | .all() 27 | ) 28 | 29 | if old_tokens: 30 | for token in old_tokens: 31 | db.session.delete(token) 32 | 33 | db.session.commit() 34 | 35 | print( 36 | "{} old tokens have been removed from the database".format(len(old_tokens)) 37 | ) 38 | 39 | else: 40 | print("No JWT's older than 5 days have been found") 41 | 42 | return old_tokens 43 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.8.4 2 | aiosignal==1.3.1 3 | alembic==1.10.4 4 | appdirs==1.4.4 5 | asgiref==3.6.0 6 | async-timeout==4.0.2 7 | attrs==23.1.0 8 | black==23.3.0 9 | blinker==1.6.2 10 | certifi==2022.12.7 11 | chardet==5.1.0 12 | charset-normalizer==3.1.0 13 | click==8.1.3 14 | coverage==7.2.3 15 | Deprecated==1.2.13 16 | flake8==6.0.0 17 | Flask==2.3.1 18 | Flask-Cors==3.0.10 19 | Flask-JWT-Extended==4.4.4 20 | Flask-Limiter==3.3.0 21 | flask-marshmallow==0.15.0 22 | Flask-Migrate==4.0.4 23 | Flask-SQLAlchemy==3.0.3 24 | frozenlist==1.3.3 25 | greenlet==2.0.2 26 | idna==3.4 27 | importlib-resources==5.12.0 28 | itsdangerous==2.1.2 29 | Jinja2==3.1.2 30 | limits==3.4.0 31 | Mako==1.2.4 32 | markdown-it-py==2.2.0 33 | MarkupSafe==2.1.2 34 | marshmallow==3.19.0 35 | marshmallow-sqlalchemy==0.29.0 36 | mccabe==0.7.0 37 | mdurl==0.1.2 38 | migrate==0.3.8 39 | multidict==6.0.4 40 | mypy-extensions==1.0.0 41 | ordered-set==4.1.0 42 | packaging==23.1 43 | pathspec==0.11.1 44 | pip-review==1.3.0 45 | platformdirs==3.4.0 46 | pycodestyle==2.10.0 47 | pyflakes==3.0.1 48 | Pygments==2.15.1 49 | PyJWT==2.6.0 50 | python-dateutil==2.8.2 51 | python-dotenv==1.0.0 52 | python-editor==1.0.4 53 | redis==4.5.4 54 | regex==2023.3.23 55 | rich==13.3.4 56 | rq==1.13.0 57 | six==1.16.0 58 | SQLAlchemy==2.0.10 59 | toml==0.10.2 60 | tomli==2.0.1 61 | typing_extensions==4.5.0 62 | urllib3==2.0.0 63 | Werkzeug==2.3.0 64 | wrapt==1.15.0 65 | yarl==1.9.2 66 | --------------------------------------------------------------------------------