From efb13e40d7c1077aef33fd99f388f7011cc76314 Mon Sep 17 00:00:00 2001 From: joshijhanvi Date: Thu, 12 Oct 2023 14:35:40 +0530 Subject: [PATCH 1/6] feat: generated fastapi boilerplate with versioning and with folder structure. --- .gitignore | 267 ++++++++++++++++++++++++++++ README.md | 76 ++++++++ apps/__init__.py | 16 ++ apps/api/auth/method.py | 0 apps/api/auth/response.py | 0 apps/api/auth/service.py | 15 ++ apps/api/auth/test.py | 0 apps/api/auth/v1/view.py | 26 +++ apps/api/auth/v2/view.py | 20 +++ apps/api/auth/view.py | 24 +++ apps/constant/constant.py | 18 ++ apps/utils/helper.py | 18 ++ apps/utils/message.py | 6 + apps/utils/standard_response.py | 34 ++++ asgi.py | 12 ++ config/cors.py | 21 +++ config/env_config.py | 20 +++ config/project_path.py | 18 ++ config/shoper-app-198b2b6cbc3e.json | 13 ++ google_wallet.py | 168 +++++++++++++++++ requirements.txt | 18 ++ shoper-app-198b2b6cbc3e.json | 13 ++ 22 files changed, 803 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/__init__.py create mode 100644 apps/api/auth/method.py create mode 100644 apps/api/auth/response.py create mode 100644 apps/api/auth/service.py create mode 100644 apps/api/auth/test.py create mode 100644 apps/api/auth/v1/view.py create mode 100644 apps/api/auth/v2/view.py create mode 100644 apps/api/auth/view.py create mode 100644 apps/constant/constant.py create mode 100644 apps/utils/helper.py create mode 100644 apps/utils/message.py create mode 100644 apps/utils/standard_response.py create mode 100644 config/cors.py create mode 100644 config/env_config.py create mode 100644 config/project_path.py create mode 100644 config/shoper-app-198b2b6cbc3e.json create mode 100644 google_wallet.py create mode 100644 requirements.txt create mode 100644 shoper-app-198b2b6cbc3e.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7bf21e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,267 @@ +# Project level files +0.0.2 +alembic.ini +changes_note.txt +migrations/ +.dbdata/ +app.dp + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Backup files # +*.bak + +# C extensions +*.so + +# MacOS stuff +**/.DS_Store + +# Docker ignore +.dockerignore + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +flask_monitoringdashboard.db + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +*.pot +*.pyc +__pycache__ +db.sqlite3 +media + +#FastAPI stuff: +.pytest_cache +htmlcov +dist +site +.coverage +coverage.xml +.netlify +test.db +log.txt +docs_build +docs.zip +archive.zip + + +# Flask stuff: +# instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# Python # +*.py[cod] +*$py.class + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv/ +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# If you are using PyCharm # +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# File-based project format +*.iws + +# JIRA plugin +atlassian-ide-plugin.xml + +# Distribution / packaging +.Python build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +.pytest_cache/ +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +# .key +SECRET.key \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e26123e --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# FastAPI boilerplate + +## 🛠 Skills +Python, Fask-API, Swagger Doc, Html and Java Scripts. + +## Install + configure the project + +### 1. Linux +``` +# Create python virtual environment +python3 -m venv venv + +# Activate the python virtual environment +source venv/bin/activate + +# Install the requirements for the project into the virtual environment +pip install -r requirements.txt + +# Install the dependencies of Fast API +pip install "fastapi[all]" + +# Upgrade pip version +python -m pip install --upgrade pip==22.1.2 +``` +### 2. Windows +``` +# Create python virtual environment +conda create --name venv python=3.10.12 + +# Activate the python virtual environment +conda activate venv + +# Install the requirements for the project into the virtual environment in the following sequence: +pip install -r requirements.txt + +# Install the dependencies of Fast API +pip install "fastapi[all]" + +# Upgrade pip version +python -m pip install --upgrade pip==22.1.2 +``` + +## Use the alembic to Upgrade/Downgrade the database in the FastAPI +Note: Because by default Fastapi is provide only initial migrations. It doesn't support the upgrade and downgrade the database. +so,to perform automatic migrations follow the following steps: + +1. # To create Migration folder +python -m alembic init migrations + +2. ## update the Migrations>>env.py file o auto migrate the database. +from models import Base +target_database = Base.metadata + +4. # Perform the initial migrations +alembic revision --autogenerate -m 'initials' + +5. # Apply the changes into the database (upgrade the database) +alembic upgrade head + # To downgrade the database if required + alembic downgrade -1 + +## Run the server in development mode + +Add environment variables (given in .env) by running following command in cmd/terminal: + +Run the server +``` +python asgi.py +``` +Browse Swagger API Doc at: http://localhost:8000/docs +Browse Redoc at: http://localhost:8000/redoc + +## Release History + +* 0.1 + * Work in progress \ No newline at end of file diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..0ea1e19 --- /dev/null +++ b/apps/__init__.py @@ -0,0 +1,16 @@ +from fastapi import FastAPI +from fastapi_versioning import VersionedFastAPI + +from apps.api.auth.v1.view import router +from apps.api.auth.v2.view import authrouter +from config import cors + +# Create app object and add routes +app = FastAPI(title="Python FastAPI boilerplate", middleware=cors.middleware) + +# define router for different version +app.include_router(router, prefix="/v1", tags=["v1"]) # router for version 1 +app.include_router(authrouter, prefix="/v2", tags=["v2"]) # router for version 2 + +# # # Define version to specify version related API's. +app = VersionedFastAPI(app, version_format="{major}", prefix_format="/v{major}", enable_latest=True) \ No newline at end of file diff --git a/apps/api/auth/method.py b/apps/api/auth/method.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/auth/response.py b/apps/api/auth/response.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/auth/service.py b/apps/api/auth/service.py new file mode 100644 index 0000000..572e598 --- /dev/null +++ b/apps/api/auth/service.py @@ -0,0 +1,15 @@ +from apps.utils.standard_response import StandardResponse +from fastapi import status +from apps.constant import constant + +def get_str_name(name: str): + if name is not None: + return StandardResponse( + True, status.HTTP_200_OK, {"success": "Welcome"}, + constant.STATUS_SUCCESS + ) + else: + return StandardResponse( + True, status.HTTP_400_BAD_REQUEST, {"success": "Error"}, + constant.STATUS_ERROR + ) \ No newline at end of file diff --git a/apps/api/auth/test.py b/apps/api/auth/test.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/auth/v1/view.py b/apps/api/auth/v1/view.py new file mode 100644 index 0000000..c723d34 --- /dev/null +++ b/apps/api/auth/v1/view.py @@ -0,0 +1,26 @@ +import time +from fastapi import status +from fastapi_utils.cbv import cbv +from fastapi_utils.inferring_router import InferringRouter +from fastapi_versioning import version +from apps.constant import constant +from apps.utils.standard_response import StandardResponse + +## Load API's +router = InferringRouter() + +## Define API's here +@cbv(router) +class UserCrudApi(): + """This class is for user's CRUD operation with version 1 API's""" + + @router.get('/list/user') + @version(1) + async def list_user(self): + """This API is for list user. + """ + try: + data = "Hello there, welcome to fastapi bolierplate" + return data + except Exception as e: + return StandardResponse(False, status.HTTP_400_BAD_REQUEST, None, constant.ERROR_MSG) \ No newline at end of file diff --git a/apps/api/auth/v2/view.py b/apps/api/auth/v2/view.py new file mode 100644 index 0000000..0246cfc --- /dev/null +++ b/apps/api/auth/v2/view.py @@ -0,0 +1,20 @@ +from fastapi_utils.inferring_router import InferringRouter +from fastapi_versioning import version +from fastapi_utils.cbv import cbv +from apps.utils.standard_response import StandardResponse +from fastapi import status +from apps.constant import constant + +authrouter = InferringRouter() + + +@cbv(authrouter) +class APIView(): + @authrouter.get("/list") + @version(2) + def get_list(self): + try: + response = { "data": "User's list data" } + return StandardResponse(True, status.HTTP_200_OK, response, constant.STATUS_SUCCESS) + except Exception as e: + return StandardResponse(False, status.HTTP_400_BAD_REQUEST, None, constant.ERROR_MSG) \ No newline at end of file diff --git a/apps/api/auth/view.py b/apps/api/auth/view.py new file mode 100644 index 0000000..8def75a --- /dev/null +++ b/apps/api/auth/view.py @@ -0,0 +1,24 @@ +from fastapi import status +from fastapi_utils.cbv import cbv +from fastapi_utils.inferring_router import InferringRouter + +from apps.constant import constant +from apps.utils.standard_response import StandardResponse + +## Load API's +router = InferringRouter() + +## Define API's here +@cbv(router) +class UserCrudApi(): + """This class is for user's CRUD operation with version 1 API's""" + + @router.get('/list/user') + async def list_user(self): + """This API is for list user. + """ + try: + response = "Hello there, welcome to fastapi bolierplate" + return response + except Exception as e: + return StandardResponse(False, status.HTTP_400_BAD_REQUEST, None, constant.ERROR_MSG) \ No newline at end of file diff --git a/apps/constant/constant.py b/apps/constant/constant.py new file mode 100644 index 0000000..0ea7462 --- /dev/null +++ b/apps/constant/constant.py @@ -0,0 +1,18 @@ +STATUS_SUCCESS = "success" +STATUS_FAIL = "fail" +STATUS_ERROR = "error" +STATUS_NULL = None +STATUS_TRUE = True +STATUS_FALSE = False +API_V1 = "v1" +API_V2 = "v2" + +## success message ## +USER_CREATED = "User created successfully!" +USER_UPDATED = "User updated sucessfully!" +USER_DELETED = "User deleted successfully!" +USER_LIST = "List of all users!" + + +## error message ## +ERROR_MSG = "Error while creating user!" \ No newline at end of file diff --git a/apps/utils/helper.py b/apps/utils/helper.py new file mode 100644 index 0000000..7099989 --- /dev/null +++ b/apps/utils/helper.py @@ -0,0 +1,18 @@ +from passlib.context import CryptContext + +class PasswordUtils(): + """This class is used to manage password management""" + + def __init__(self): + self.pwd_context = CryptContext(schemes=['bcrypt'], deprecated="auto") + + def hash_password(self, password: str): + """ + This function is used to hash password + Arguments: + password(str) : password argument of string format. + + Returns: + Hash of the password + """ + return self.pwd_context.hash(password) \ No newline at end of file diff --git a/apps/utils/message.py b/apps/utils/message.py new file mode 100644 index 0000000..6d57d9e --- /dev/null +++ b/apps/utils/message.py @@ -0,0 +1,6 @@ +# predefined messages. + +class ErrorMessage: + USER_NOT_SAVED = "User is not saved" + emailRequired = "E-Mail is required" + emailAlreadyExist = "E-Mail already exists." \ No newline at end of file diff --git a/apps/utils/standard_response.py b/apps/utils/standard_response.py new file mode 100644 index 0000000..11ef385 --- /dev/null +++ b/apps/utils/standard_response.py @@ -0,0 +1,34 @@ +from apps.constant import constant +from fastapi.responses import JSONResponse + +class StandardResponse: + """This class is universal to return standard API responses + + Attributes: + status (int): The http status response from API + data (dict/list): The Data from API + message (str): The message from the API + """ + + def __init__(self, status, status_code: int, data: dict, message: str) -> None: + """This function defines arguments that are used in the class + + Arguments: + status (str): The success/failure status. + status_code (int): The http status response from API + data (dict/list): The Data from API + message (str): The message from the API + + Returns: + Returns the API standard response + """ + self.status = status + self.status_code = status_code + self.data = data + self.message = message + + @property + def make(self) -> dict: + self.status = constant.STATUS_SUCCESS if self.status in [201, 200] else constant.STATUS_FAIL + response = {'status': self.status, 'data': self.data, 'message': self.message} + return JSONResponse(content=response, status_code=self.status_code) \ No newline at end of file diff --git a/asgi.py b/asgi.py index e69de29..36b92fe 100644 --- a/asgi.py +++ b/asgi.py @@ -0,0 +1,12 @@ +import os +import uvicorn +from apps.__init__ import app +from config.env_config import load_dotenv + +load_dotenv() + +if __name__ == "__main__": + uvicorn.run("asgi:app", + host=os.environ.get('SERVER_HOST'), + port=int(os.environ.get('SERVER_PORT')), + reload=True) \ No newline at end of file diff --git a/config/cors.py b/config/cors.py new file mode 100644 index 0000000..dfc73aa --- /dev/null +++ b/config/cors.py @@ -0,0 +1,21 @@ +from fastapi import FastAPI +from starlette.middleware import Middleware +from fastapi.middleware.cors import CORSMiddleware + + +##### CORS configuration ##### +CORS_ALLOW_ORIGINS = ["*"] + +CORS_ALLOW_METHODS = ["*"] + +CORS_ALLOW_HEADERS = ["*"] + +middleware = [ + Middleware( + CORSMiddleware, + allow_origins=CORS_ALLOW_ORIGINS, + allow_credentials=True, + allow_methods=CORS_ALLOW_METHODS, + allow_headers=CORS_ALLOW_HEADERS + ) +] \ No newline at end of file diff --git a/config/env_config.py b/config/env_config.py new file mode 100644 index 0000000..cd05821 --- /dev/null +++ b/config/env_config.py @@ -0,0 +1,20 @@ +from dotenv import load_dotenv +from os.path import join +from .project_path import BASE_DIR +import os + +##### ENV configuration ##### +dotenv_path = join(BASE_DIR, ".env") +load_dotenv(dotenv_path) + + +## Database deatils ## +DATABASE_NAME = os.environ.get("DATABASE_NAME") +DATABASE_USER = os.environ.get("DATABASE_USER") +DATABASE_PASSWORD = os.environ.get("DATABASE_PASSWORD") +DATABASE_HOST = os.environ.get("DATABASE_HOST") +DATABASE_PORT = os.environ.get("DATABASE_PORT") + + + + diff --git a/config/project_path.py b/config/project_path.py new file mode 100644 index 0000000..9855c8f --- /dev/null +++ b/config/project_path.py @@ -0,0 +1,18 @@ +from os.path import abspath, basename, dirname, join + + +# ##### PATH CONFIGURATION ################################ + +# fetch FastAPI's project directory +BASE_DIR = dirname(dirname(abspath(__file__))) + +# the name of the whole site +SITE_NAME = basename(BASE_DIR) + +# look for templates here +# This is an internal setting, used in the TEMPLATES directive +PROJECT_TEMPLATES_ROOT = join(BASE_DIR, 'assets', 'templates') + +PROJECT_STATIC_ROOT = join(BASE_DIR, 'assets', 'static') + +MEDIA_ROOT = join(BASE_DIR, 'media') diff --git a/config/shoper-app-198b2b6cbc3e.json b/config/shoper-app-198b2b6cbc3e.json new file mode 100644 index 0000000..3604837 --- /dev/null +++ b/config/shoper-app-198b2b6cbc3e.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "shoper-app", + "private_key_id": "198b2b6cbc3ecdef56273b2fdd7b5d8e54502af2", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCkwdqvaE1fwGIa\nhS/9rXWg5IE5J/YMtelo9ShKxeKaFZe3usKNTWajsSysPQNt35GQYd3zU0mguxQ4\n+Yoyv0bGiNv9WPicONid/Ju/8oQvu4Le2lbgsQeiZc4fGYCYgibLzKpOKEKMkQ9w\nSiuD+M1eVwkoCFekQgENVOhE5p1/7+pgGPiirRLFhX4aUr7f1TjZIhrQpCLSm8vN\n26tX/jTJjdP5CZ+gS7ILSpISGMLVjmqdZMZZa3IQNY7fJcZ4rjsjMjgXMETCb/yu\n3IarLxYM93TlNtwafxZ/4QBBL8R88l4IbbRFM03jvJra7N9tdN4sQ4wnA4uWYxMU\n3hPiMUX5AgMBAAECggEAA/i/wkU7KKqZ75/UA5Z0IYQIpz2GBXhRwpI2xBcUapiV\nKI+jvRjHarBGQaoTd4lX0eynpB2j030j1GjHLFVNY5+5rLHBn0oRuYmoO1Hhd/rm\n9LAIpdBJ43mqGAVUth7crqjsVq4XrQBNNTmDsyHjDE0zhXSU0+FnpmNGDLn1ER57\nQzWnTEqbnNXtBZMol0FdfhkMj7t03C1jNimeD0jHYmL1iB2glZxAleZPPkHBrdsN\nf+urZPbQHu0eMA2PwLiOvar//SYInVPMS++gM0CbRrgclXbjfHzUSif1f1gBfHDc\nqeJGiVIas6RUIM2Lk7Sz0RgSPE9cKjxjI2WFOFZgIQKBgQDkJedIBwON1/iXmvG0\nS8DdjCVdCHnnHuDy/xS0Ocs0ugeA4tN+jRtHKivBNHyW1NejnQ6DFaD67pJ94Vj2\n3SBTa1dNF4D4/vzw6sCmrsZwYcuqjVUaFEugdi7s2GAPgND43mS7rBJ14FpFrXAM\nhPNPb2fJygvTbozvPIP4Gao8UQKBgQC43tt1bGsTgjNGQ3WokNyuB0t/7Qa9S/et\nxQq+Sj5lk58QsdV4zkiSAPhjm2iKNhXDxd6cBncjbERDH5gAPzrdj+v0l4FQlRh6\nP3uo0I0meHqEc27pVL9rWHa8ZrZgCy8xNN6Fb7p4kFJ7XZXpOJcGOBFqCa3wLHqE\nruqx2yWNKQKBgBorEs0bKNgzJmtVNVYFvlhrA7oZB8pvq0OT6G8Hlfw1PjkVS0bf\nrnpKJvyhJY0zWoyEri5w46cEiD7yAv9Fu7h1vmy0PnHQ5XhIpNI5h79KKE8mqNU1\n8Lq184ntA4+jqdRxxcIU6YUlt5T4YLq+4R2CXLgzeYnFy1qBaW2im/kRAoGAE4Df\nYjn36ez4f9cqGIh/35RBcNOOvHXBQYHiKkUm5Ax44YgBX2dT3KNhkRCaLMqb7TV4\n0LkV5JTNds9kd9Iz4aAHYpyBNgEkvfDomNy3p3Faa5LKBq+8KhUBIcssPmGvrt9H\nAojRAVsoeH9dC2e+9xb/L1KqGQZ4Pns9o1ndUlECgYEAqFH1mQzaKJQZ6mtlvlMS\nhymVJikfsC8sJl1ye3KZmLIu7PqfDfPSMca1ju/71f2h2xvjO4QdDuFLheeQLgzO\nahsqwfGGRSOJZth6OPUdb6Ae+fAt/MqM2uy0H4TOFKg43c0UTolUUXHUsw+1EwMd\nOWhTFLztsq4IX4G61SM3MOQ=\n-----END PRIVATE KEY-----\n", + "client_email": "event-680@shoper-app.iam.gserviceaccount.com", + "client_id": "112632098951209020092", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/event-680%40shoper-app.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +} diff --git a/google_wallet.py b/google_wallet.py new file mode 100644 index 0000000..f539bb8 --- /dev/null +++ b/google_wallet.py @@ -0,0 +1,168 @@ +import uuid +import os +import json +from google.auth.transport.requests import AuthorizedSession +from google.oauth2.service_account import Credentials +from google.auth import jwt, crypt +from dotenv import load_dotenv + +load_dotenv() + + +class DemoGeneric: + """ + Thic class for authenticate with google wallet credentials, and create + passes class and objects with jwt token. + """ + def __init__(self): + self.key_file_path = os.environ.get('GOOGLE_APPLICATION_CREDENTIALS') + self.base_url = 'https://walletobjects.googleapis.com/walletobjects/v1' + self.batch_url = 'https://walletobjects.googleapis.com/batch' + self.class_url = f'{self.base_url}/genericClass' + self.object_url = f'{self.base_url}/genericObject' + + # Set up authenticated client + self.auth() + + def auth(self): + """Create authenticated HTTP client using a service account file.""" + self.credentials = Credentials.from_service_account_file( + self.key_file_path, + scopes=['https://www.googleapis.com/auth/wallet_object.issuer']) + + self.http_client = AuthorizedSession(self.credentials) + + def create_object(self, issuer_id: str, class_suffix: str, + object_suffix: str) -> str: + """Create an object. + + Args: + issuer_id (str): The issuer ID being used for this request. + class_suffix (str): Developer-defined unique ID for the pass class. + object_suffix (str): Developer-defined unique ID for the pass object. + + Returns: + The pass object ID: f"{issuer_id}.{object_suffix}" + """ + + # Check if the object exists + response = self.http_client.get( + url=f'{self.object_url}/{issuer_id}.{object_suffix}') + + if response.status_code == 200: + print(f'Object {issuer_id}.{object_suffix} already exists!') + print("**-*-*--*", response.text) + return f'{issuer_id}.{object_suffix}' + + elif response.status_code != 404: + # Something else went wrong... + print(response.text) + return f'{issuer_id}.{object_suffix}' + + # See link below for more information on required properties + # https://developers.google.com/wallet/generic/rest/v1/genericobject + # Creating a new object + new_object = { + 'id': f'{issuer_id}.{object_suffix}', + 'classId': f'{class_suffix}', + 'state': 'ACTIVE', + 'textModulesData': [{ + 'header': 'Text module header', + 'body': 'Text module body', + 'id': 'TEXT_MODULE_ID' + }], + 'linksModuleData': { + 'uris': [{ + 'uri': 'http://maps.google.com/', + 'description': 'Link module URI description', + 'id': 'LINK_MODULE_URI_ID' + }, { + 'uri': 'tel:6505555555', + 'description': 'Link module tel description', + 'id': 'LINK_MODULE_TEL_ID' + }] + }, + 'imageModulesData': [{ + 'mainImage': { + 'sourceUri': { + 'uri': + 'https://evrc-everycred-public.s3.ap-south-1.amazonaws.com/media/1/images/cbeee1ae-9f5c-4b88-89df-3ddfac417d9d.jpg' + }, + 'contentDescription': { + 'defaultValue': { + 'language': 'en-US', + 'value': 'Image module description' + } + } + }, + 'id': 'IMAGE_MODULE_ID' + }], + + ## QR code json + 'barcode': { + 'type': 'QR_CODE', + 'value': 'https://staging-verifier.everycred.com/83243cac-b8b8-47ab-a0f4-1dbb0c8be2d2' + }, + 'cardTitle': { + 'defaultValue': { + 'language': 'en-US', + 'value': 'ViitorCloud Technologies Pvt. Ltd.' + } + }, + 'header': { + 'defaultValue': { + 'language': 'en-US', + 'value': 'Award Certificate' + } + }, + # Logo json + 'hexBackgroundColor': '#4285f4', + 'logo': { + 'sourceUri': { + 'uri': + 'https://evrc-everycred-public.s3.ap-south-1.amazonaws.com/media/1/images/cbeee1ae-9f5c-4b88-89df-3ddfac417d9d.jpg' + }, + 'contentDescription': { + 'defaultValue': { + 'language': 'en-US', + 'value': 'Generic card logo' + } + } + } + } + # Create the object + response = self.http_client.post(url=self.object_url, json=new_object) + + print('Object insert response') + print(response.text) + print("----------- Object ID", response.json().get('id')) + + new_class = {'id': f'{class_suffix}'} + # Create the JWT claims + claims = { + 'iss': self.credentials.service_account_email, + 'aud': 'google', + 'origins': ['http://localhost:8080'], + 'typ': 'savetowallet', + 'payload': { + # The listed classes and objects will be created + 'genericClasses': [new_class], + 'genericObjects': [new_object] + } + } + + # The service account credentials are used to sign the JWT + signer = crypt.RSASigner.from_service_account_file(self.key_file_path) + token = jwt.encode(signer, claims).decode('utf-8') + + print('Add to Google Wallet link') + obj = f'https://pay.google.com/gp/v/save/{token}' + + data = {"Google wallet object": obj} + print(data) + return data + + + +google_obj = DemoGeneric() +google_obj.create_object("3388000000022264908", "3388000000022264908.4f765bb1-25c9-48f9-8992-15ad6da8ef0c", "DemoObject5") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..81877b2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,18 @@ + +fastapi==0.85.1 +uvicorn==0.23.2 +# fastapi["all"] +# annotated-types==0.5.0 +# python-dotenv==0.21.0 +# anyio==3.7.1 +# click==8.1.7 +# colorama==0.4.6 +# exceptiongroup==1.1.3 + +# h11==0.14.0 +# idna==3.4 +# pydantic==1.10.2 +# pydantic_core==2.6.1 +# sniffio==1.3.0 +# typing_extensions==4.7.1 + diff --git a/shoper-app-198b2b6cbc3e.json b/shoper-app-198b2b6cbc3e.json new file mode 100644 index 0000000..3604837 --- /dev/null +++ b/shoper-app-198b2b6cbc3e.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "shoper-app", + "private_key_id": "198b2b6cbc3ecdef56273b2fdd7b5d8e54502af2", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCkwdqvaE1fwGIa\nhS/9rXWg5IE5J/YMtelo9ShKxeKaFZe3usKNTWajsSysPQNt35GQYd3zU0mguxQ4\n+Yoyv0bGiNv9WPicONid/Ju/8oQvu4Le2lbgsQeiZc4fGYCYgibLzKpOKEKMkQ9w\nSiuD+M1eVwkoCFekQgENVOhE5p1/7+pgGPiirRLFhX4aUr7f1TjZIhrQpCLSm8vN\n26tX/jTJjdP5CZ+gS7ILSpISGMLVjmqdZMZZa3IQNY7fJcZ4rjsjMjgXMETCb/yu\n3IarLxYM93TlNtwafxZ/4QBBL8R88l4IbbRFM03jvJra7N9tdN4sQ4wnA4uWYxMU\n3hPiMUX5AgMBAAECggEAA/i/wkU7KKqZ75/UA5Z0IYQIpz2GBXhRwpI2xBcUapiV\nKI+jvRjHarBGQaoTd4lX0eynpB2j030j1GjHLFVNY5+5rLHBn0oRuYmoO1Hhd/rm\n9LAIpdBJ43mqGAVUth7crqjsVq4XrQBNNTmDsyHjDE0zhXSU0+FnpmNGDLn1ER57\nQzWnTEqbnNXtBZMol0FdfhkMj7t03C1jNimeD0jHYmL1iB2glZxAleZPPkHBrdsN\nf+urZPbQHu0eMA2PwLiOvar//SYInVPMS++gM0CbRrgclXbjfHzUSif1f1gBfHDc\nqeJGiVIas6RUIM2Lk7Sz0RgSPE9cKjxjI2WFOFZgIQKBgQDkJedIBwON1/iXmvG0\nS8DdjCVdCHnnHuDy/xS0Ocs0ugeA4tN+jRtHKivBNHyW1NejnQ6DFaD67pJ94Vj2\n3SBTa1dNF4D4/vzw6sCmrsZwYcuqjVUaFEugdi7s2GAPgND43mS7rBJ14FpFrXAM\nhPNPb2fJygvTbozvPIP4Gao8UQKBgQC43tt1bGsTgjNGQ3WokNyuB0t/7Qa9S/et\nxQq+Sj5lk58QsdV4zkiSAPhjm2iKNhXDxd6cBncjbERDH5gAPzrdj+v0l4FQlRh6\nP3uo0I0meHqEc27pVL9rWHa8ZrZgCy8xNN6Fb7p4kFJ7XZXpOJcGOBFqCa3wLHqE\nruqx2yWNKQKBgBorEs0bKNgzJmtVNVYFvlhrA7oZB8pvq0OT6G8Hlfw1PjkVS0bf\nrnpKJvyhJY0zWoyEri5w46cEiD7yAv9Fu7h1vmy0PnHQ5XhIpNI5h79KKE8mqNU1\n8Lq184ntA4+jqdRxxcIU6YUlt5T4YLq+4R2CXLgzeYnFy1qBaW2im/kRAoGAE4Df\nYjn36ez4f9cqGIh/35RBcNOOvHXBQYHiKkUm5Ax44YgBX2dT3KNhkRCaLMqb7TV4\n0LkV5JTNds9kd9Iz4aAHYpyBNgEkvfDomNy3p3Faa5LKBq+8KhUBIcssPmGvrt9H\nAojRAVsoeH9dC2e+9xb/L1KqGQZ4Pns9o1ndUlECgYEAqFH1mQzaKJQZ6mtlvlMS\nhymVJikfsC8sJl1ye3KZmLIu7PqfDfPSMca1ju/71f2h2xvjO4QdDuFLheeQLgzO\nahsqwfGGRSOJZth6OPUdb6Ae+fAt/MqM2uy0H4TOFKg43c0UTolUUXHUsw+1EwMd\nOWhTFLztsq4IX4G61SM3MOQ=\n-----END PRIVATE KEY-----\n", + "client_email": "event-680@shoper-app.iam.gserviceaccount.com", + "client_id": "112632098951209020092", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/event-680%40shoper-app.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +} From 17cfa55dabb73d27f03d207a8ec57a3577c96276 Mon Sep 17 00:00:00 2001 From: joshijhanvi Date: Tue, 17 Oct 2023 14:06:11 +0530 Subject: [PATCH 2/6] fix: changes in versioning for API's and in structure. --- apps/__init__.py | 11 +- apps/api/auth/v1/view.py | 26 ----- apps/api/auth/v2/view.py | 20 ---- apps/api/auth/view.py | 36 ++++-- config/database.py | 23 ++++ config/shoper-app-198b2b6cbc3e.json | 13 --- google_wallet.py | 168 ---------------------------- shoper-app-198b2b6cbc3e.json | 13 --- 8 files changed, 56 insertions(+), 254 deletions(-) create mode 100644 config/database.py delete mode 100644 config/shoper-app-198b2b6cbc3e.json delete mode 100644 google_wallet.py delete mode 100644 shoper-app-198b2b6cbc3e.json diff --git a/apps/__init__.py b/apps/__init__.py index 0ea1e19..f272f7a 100644 --- a/apps/__init__.py +++ b/apps/__init__.py @@ -1,16 +1,13 @@ +from config import cors from fastapi import FastAPI from fastapi_versioning import VersionedFastAPI - -from apps.api.auth.v1.view import router -from apps.api.auth.v2.view import authrouter -from config import cors +from apps.api.auth.view import defaultrouter # Create app object and add routes app = FastAPI(title="Python FastAPI boilerplate", middleware=cors.middleware) # define router for different version -app.include_router(router, prefix="/v1", tags=["v1"]) # router for version 1 -app.include_router(authrouter, prefix="/v2", tags=["v2"]) # router for version 2 +app.include_router(defaultrouter) # router for version 1 -# # # Define version to specify version related API's. +# Define version to specify version related API's. app = VersionedFastAPI(app, version_format="{major}", prefix_format="/v{major}", enable_latest=True) \ No newline at end of file diff --git a/apps/api/auth/v1/view.py b/apps/api/auth/v1/view.py index c723d34..e69de29 100644 --- a/apps/api/auth/v1/view.py +++ b/apps/api/auth/v1/view.py @@ -1,26 +0,0 @@ -import time -from fastapi import status -from fastapi_utils.cbv import cbv -from fastapi_utils.inferring_router import InferringRouter -from fastapi_versioning import version -from apps.constant import constant -from apps.utils.standard_response import StandardResponse - -## Load API's -router = InferringRouter() - -## Define API's here -@cbv(router) -class UserCrudApi(): - """This class is for user's CRUD operation with version 1 API's""" - - @router.get('/list/user') - @version(1) - async def list_user(self): - """This API is for list user. - """ - try: - data = "Hello there, welcome to fastapi bolierplate" - return data - except Exception as e: - return StandardResponse(False, status.HTTP_400_BAD_REQUEST, None, constant.ERROR_MSG) \ No newline at end of file diff --git a/apps/api/auth/v2/view.py b/apps/api/auth/v2/view.py index 0246cfc..e69de29 100644 --- a/apps/api/auth/v2/view.py +++ b/apps/api/auth/v2/view.py @@ -1,20 +0,0 @@ -from fastapi_utils.inferring_router import InferringRouter -from fastapi_versioning import version -from fastapi_utils.cbv import cbv -from apps.utils.standard_response import StandardResponse -from fastapi import status -from apps.constant import constant - -authrouter = InferringRouter() - - -@cbv(authrouter) -class APIView(): - @authrouter.get("/list") - @version(2) - def get_list(self): - try: - response = { "data": "User's list data" } - return StandardResponse(True, status.HTTP_200_OK, response, constant.STATUS_SUCCESS) - except Exception as e: - return StandardResponse(False, status.HTTP_400_BAD_REQUEST, None, constant.ERROR_MSG) \ No newline at end of file diff --git a/apps/api/auth/view.py b/apps/api/auth/view.py index 8def75a..d51454c 100644 --- a/apps/api/auth/view.py +++ b/apps/api/auth/view.py @@ -1,24 +1,46 @@ from fastapi import status from fastapi_utils.cbv import cbv -from fastapi_utils.inferring_router import InferringRouter - +from fastapi import APIRouter from apps.constant import constant +from fastapi_versioning import version +from fastapi_utils.inferring_router import InferringRouter from apps.utils.standard_response import StandardResponse ## Load API's -router = InferringRouter() +defaultrouter = APIRouter() ## Define API's here -@cbv(router) +@cbv(defaultrouter) class UserCrudApi(): """This class is for user's CRUD operation with version 1 API's""" - @router.get('/list/user') + @defaultrouter.get('/v1/list/user') + @version(1) async def list_user(self): """This API is for list user. """ try: - response = "Hello there, welcome to fastapi bolierplate" - return response + data = "Hello there, welcome to fastapi bolierplate" + return data + except Exception as e: + return StandardResponse(False, status.HTTP_400_BAD_REQUEST, None, constant.ERROR_MSG) + + @defaultrouter.post('/v1/create/user') + @version(1) + async def list_user(self): + """This API is for list user. + """ + try: + data = "Hello there, welcome to fastapi bolierplate" + return data + except Exception as e: + return StandardResponse(False, status.HTTP_400_BAD_REQUEST, None, constant.ERROR_MSG) + + @defaultrouter.get("/v2/list") + @version(2) + async def get_list(self): + try: + response = { "data": "User's list data" } + return StandardResponse(True, status.HTTP_200_OK, response, constant.STATUS_SUCCESS) except Exception as e: return StandardResponse(False, status.HTTP_400_BAD_REQUEST, None, constant.ERROR_MSG) \ No newline at end of file diff --git a/config/database.py b/config/database.py new file mode 100644 index 0000000..411195b --- /dev/null +++ b/config/database.py @@ -0,0 +1,23 @@ +"""This config file will define Database Url and configurations""" +import os +from config import env_config +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.ext.declarative import declarative_base + + +# ##### DATABASE CONFIGURATION ############################ +SQLALCHEMY_DATABASE_URL = f'mysql+pymysql://{env_config.DATABASE_USER}:{env_config.DATABASE_PASSWORD}@{env_config.DATABASE_HOST}:{env_config.DATABASE_PORT}/{env_config.DATABASE_NAME}' + +# Create engine by sqlalchemy db url +engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_recycle=3600, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) + +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/config/shoper-app-198b2b6cbc3e.json b/config/shoper-app-198b2b6cbc3e.json deleted file mode 100644 index 3604837..0000000 --- a/config/shoper-app-198b2b6cbc3e.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "type": "service_account", - "project_id": "shoper-app", - "private_key_id": "198b2b6cbc3ecdef56273b2fdd7b5d8e54502af2", - "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCkwdqvaE1fwGIa\nhS/9rXWg5IE5J/YMtelo9ShKxeKaFZe3usKNTWajsSysPQNt35GQYd3zU0mguxQ4\n+Yoyv0bGiNv9WPicONid/Ju/8oQvu4Le2lbgsQeiZc4fGYCYgibLzKpOKEKMkQ9w\nSiuD+M1eVwkoCFekQgENVOhE5p1/7+pgGPiirRLFhX4aUr7f1TjZIhrQpCLSm8vN\n26tX/jTJjdP5CZ+gS7ILSpISGMLVjmqdZMZZa3IQNY7fJcZ4rjsjMjgXMETCb/yu\n3IarLxYM93TlNtwafxZ/4QBBL8R88l4IbbRFM03jvJra7N9tdN4sQ4wnA4uWYxMU\n3hPiMUX5AgMBAAECggEAA/i/wkU7KKqZ75/UA5Z0IYQIpz2GBXhRwpI2xBcUapiV\nKI+jvRjHarBGQaoTd4lX0eynpB2j030j1GjHLFVNY5+5rLHBn0oRuYmoO1Hhd/rm\n9LAIpdBJ43mqGAVUth7crqjsVq4XrQBNNTmDsyHjDE0zhXSU0+FnpmNGDLn1ER57\nQzWnTEqbnNXtBZMol0FdfhkMj7t03C1jNimeD0jHYmL1iB2glZxAleZPPkHBrdsN\nf+urZPbQHu0eMA2PwLiOvar//SYInVPMS++gM0CbRrgclXbjfHzUSif1f1gBfHDc\nqeJGiVIas6RUIM2Lk7Sz0RgSPE9cKjxjI2WFOFZgIQKBgQDkJedIBwON1/iXmvG0\nS8DdjCVdCHnnHuDy/xS0Ocs0ugeA4tN+jRtHKivBNHyW1NejnQ6DFaD67pJ94Vj2\n3SBTa1dNF4D4/vzw6sCmrsZwYcuqjVUaFEugdi7s2GAPgND43mS7rBJ14FpFrXAM\nhPNPb2fJygvTbozvPIP4Gao8UQKBgQC43tt1bGsTgjNGQ3WokNyuB0t/7Qa9S/et\nxQq+Sj5lk58QsdV4zkiSAPhjm2iKNhXDxd6cBncjbERDH5gAPzrdj+v0l4FQlRh6\nP3uo0I0meHqEc27pVL9rWHa8ZrZgCy8xNN6Fb7p4kFJ7XZXpOJcGOBFqCa3wLHqE\nruqx2yWNKQKBgBorEs0bKNgzJmtVNVYFvlhrA7oZB8pvq0OT6G8Hlfw1PjkVS0bf\nrnpKJvyhJY0zWoyEri5w46cEiD7yAv9Fu7h1vmy0PnHQ5XhIpNI5h79KKE8mqNU1\n8Lq184ntA4+jqdRxxcIU6YUlt5T4YLq+4R2CXLgzeYnFy1qBaW2im/kRAoGAE4Df\nYjn36ez4f9cqGIh/35RBcNOOvHXBQYHiKkUm5Ax44YgBX2dT3KNhkRCaLMqb7TV4\n0LkV5JTNds9kd9Iz4aAHYpyBNgEkvfDomNy3p3Faa5LKBq+8KhUBIcssPmGvrt9H\nAojRAVsoeH9dC2e+9xb/L1KqGQZ4Pns9o1ndUlECgYEAqFH1mQzaKJQZ6mtlvlMS\nhymVJikfsC8sJl1ye3KZmLIu7PqfDfPSMca1ju/71f2h2xvjO4QdDuFLheeQLgzO\nahsqwfGGRSOJZth6OPUdb6Ae+fAt/MqM2uy0H4TOFKg43c0UTolUUXHUsw+1EwMd\nOWhTFLztsq4IX4G61SM3MOQ=\n-----END PRIVATE KEY-----\n", - "client_email": "event-680@shoper-app.iam.gserviceaccount.com", - "client_id": "112632098951209020092", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/event-680%40shoper-app.iam.gserviceaccount.com", - "universe_domain": "googleapis.com" -} diff --git a/google_wallet.py b/google_wallet.py deleted file mode 100644 index f539bb8..0000000 --- a/google_wallet.py +++ /dev/null @@ -1,168 +0,0 @@ -import uuid -import os -import json -from google.auth.transport.requests import AuthorizedSession -from google.oauth2.service_account import Credentials -from google.auth import jwt, crypt -from dotenv import load_dotenv - -load_dotenv() - - -class DemoGeneric: - """ - Thic class for authenticate with google wallet credentials, and create - passes class and objects with jwt token. - """ - def __init__(self): - self.key_file_path = os.environ.get('GOOGLE_APPLICATION_CREDENTIALS') - self.base_url = 'https://walletobjects.googleapis.com/walletobjects/v1' - self.batch_url = 'https://walletobjects.googleapis.com/batch' - self.class_url = f'{self.base_url}/genericClass' - self.object_url = f'{self.base_url}/genericObject' - - # Set up authenticated client - self.auth() - - def auth(self): - """Create authenticated HTTP client using a service account file.""" - self.credentials = Credentials.from_service_account_file( - self.key_file_path, - scopes=['https://www.googleapis.com/auth/wallet_object.issuer']) - - self.http_client = AuthorizedSession(self.credentials) - - def create_object(self, issuer_id: str, class_suffix: str, - object_suffix: str) -> str: - """Create an object. - - Args: - issuer_id (str): The issuer ID being used for this request. - class_suffix (str): Developer-defined unique ID for the pass class. - object_suffix (str): Developer-defined unique ID for the pass object. - - Returns: - The pass object ID: f"{issuer_id}.{object_suffix}" - """ - - # Check if the object exists - response = self.http_client.get( - url=f'{self.object_url}/{issuer_id}.{object_suffix}') - - if response.status_code == 200: - print(f'Object {issuer_id}.{object_suffix} already exists!') - print("**-*-*--*", response.text) - return f'{issuer_id}.{object_suffix}' - - elif response.status_code != 404: - # Something else went wrong... - print(response.text) - return f'{issuer_id}.{object_suffix}' - - # See link below for more information on required properties - # https://developers.google.com/wallet/generic/rest/v1/genericobject - # Creating a new object - new_object = { - 'id': f'{issuer_id}.{object_suffix}', - 'classId': f'{class_suffix}', - 'state': 'ACTIVE', - 'textModulesData': [{ - 'header': 'Text module header', - 'body': 'Text module body', - 'id': 'TEXT_MODULE_ID' - }], - 'linksModuleData': { - 'uris': [{ - 'uri': 'http://maps.google.com/', - 'description': 'Link module URI description', - 'id': 'LINK_MODULE_URI_ID' - }, { - 'uri': 'tel:6505555555', - 'description': 'Link module tel description', - 'id': 'LINK_MODULE_TEL_ID' - }] - }, - 'imageModulesData': [{ - 'mainImage': { - 'sourceUri': { - 'uri': - 'https://evrc-everycred-public.s3.ap-south-1.amazonaws.com/media/1/images/cbeee1ae-9f5c-4b88-89df-3ddfac417d9d.jpg' - }, - 'contentDescription': { - 'defaultValue': { - 'language': 'en-US', - 'value': 'Image module description' - } - } - }, - 'id': 'IMAGE_MODULE_ID' - }], - - ## QR code json - 'barcode': { - 'type': 'QR_CODE', - 'value': 'https://staging-verifier.everycred.com/83243cac-b8b8-47ab-a0f4-1dbb0c8be2d2' - }, - 'cardTitle': { - 'defaultValue': { - 'language': 'en-US', - 'value': 'ViitorCloud Technologies Pvt. Ltd.' - } - }, - 'header': { - 'defaultValue': { - 'language': 'en-US', - 'value': 'Award Certificate' - } - }, - # Logo json - 'hexBackgroundColor': '#4285f4', - 'logo': { - 'sourceUri': { - 'uri': - 'https://evrc-everycred-public.s3.ap-south-1.amazonaws.com/media/1/images/cbeee1ae-9f5c-4b88-89df-3ddfac417d9d.jpg' - }, - 'contentDescription': { - 'defaultValue': { - 'language': 'en-US', - 'value': 'Generic card logo' - } - } - } - } - # Create the object - response = self.http_client.post(url=self.object_url, json=new_object) - - print('Object insert response') - print(response.text) - print("----------- Object ID", response.json().get('id')) - - new_class = {'id': f'{class_suffix}'} - # Create the JWT claims - claims = { - 'iss': self.credentials.service_account_email, - 'aud': 'google', - 'origins': ['http://localhost:8080'], - 'typ': 'savetowallet', - 'payload': { - # The listed classes and objects will be created - 'genericClasses': [new_class], - 'genericObjects': [new_object] - } - } - - # The service account credentials are used to sign the JWT - signer = crypt.RSASigner.from_service_account_file(self.key_file_path) - token = jwt.encode(signer, claims).decode('utf-8') - - print('Add to Google Wallet link') - obj = f'https://pay.google.com/gp/v/save/{token}' - - data = {"Google wallet object": obj} - print(data) - return data - - - -google_obj = DemoGeneric() -google_obj.create_object("3388000000022264908", "3388000000022264908.4f765bb1-25c9-48f9-8992-15ad6da8ef0c", "DemoObject5") \ No newline at end of file diff --git a/shoper-app-198b2b6cbc3e.json b/shoper-app-198b2b6cbc3e.json deleted file mode 100644 index 3604837..0000000 --- a/shoper-app-198b2b6cbc3e.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "type": "service_account", - "project_id": "shoper-app", - "private_key_id": "198b2b6cbc3ecdef56273b2fdd7b5d8e54502af2", - "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCkwdqvaE1fwGIa\nhS/9rXWg5IE5J/YMtelo9ShKxeKaFZe3usKNTWajsSysPQNt35GQYd3zU0mguxQ4\n+Yoyv0bGiNv9WPicONid/Ju/8oQvu4Le2lbgsQeiZc4fGYCYgibLzKpOKEKMkQ9w\nSiuD+M1eVwkoCFekQgENVOhE5p1/7+pgGPiirRLFhX4aUr7f1TjZIhrQpCLSm8vN\n26tX/jTJjdP5CZ+gS7ILSpISGMLVjmqdZMZZa3IQNY7fJcZ4rjsjMjgXMETCb/yu\n3IarLxYM93TlNtwafxZ/4QBBL8R88l4IbbRFM03jvJra7N9tdN4sQ4wnA4uWYxMU\n3hPiMUX5AgMBAAECggEAA/i/wkU7KKqZ75/UA5Z0IYQIpz2GBXhRwpI2xBcUapiV\nKI+jvRjHarBGQaoTd4lX0eynpB2j030j1GjHLFVNY5+5rLHBn0oRuYmoO1Hhd/rm\n9LAIpdBJ43mqGAVUth7crqjsVq4XrQBNNTmDsyHjDE0zhXSU0+FnpmNGDLn1ER57\nQzWnTEqbnNXtBZMol0FdfhkMj7t03C1jNimeD0jHYmL1iB2glZxAleZPPkHBrdsN\nf+urZPbQHu0eMA2PwLiOvar//SYInVPMS++gM0CbRrgclXbjfHzUSif1f1gBfHDc\nqeJGiVIas6RUIM2Lk7Sz0RgSPE9cKjxjI2WFOFZgIQKBgQDkJedIBwON1/iXmvG0\nS8DdjCVdCHnnHuDy/xS0Ocs0ugeA4tN+jRtHKivBNHyW1NejnQ6DFaD67pJ94Vj2\n3SBTa1dNF4D4/vzw6sCmrsZwYcuqjVUaFEugdi7s2GAPgND43mS7rBJ14FpFrXAM\nhPNPb2fJygvTbozvPIP4Gao8UQKBgQC43tt1bGsTgjNGQ3WokNyuB0t/7Qa9S/et\nxQq+Sj5lk58QsdV4zkiSAPhjm2iKNhXDxd6cBncjbERDH5gAPzrdj+v0l4FQlRh6\nP3uo0I0meHqEc27pVL9rWHa8ZrZgCy8xNN6Fb7p4kFJ7XZXpOJcGOBFqCa3wLHqE\nruqx2yWNKQKBgBorEs0bKNgzJmtVNVYFvlhrA7oZB8pvq0OT6G8Hlfw1PjkVS0bf\nrnpKJvyhJY0zWoyEri5w46cEiD7yAv9Fu7h1vmy0PnHQ5XhIpNI5h79KKE8mqNU1\n8Lq184ntA4+jqdRxxcIU6YUlt5T4YLq+4R2CXLgzeYnFy1qBaW2im/kRAoGAE4Df\nYjn36ez4f9cqGIh/35RBcNOOvHXBQYHiKkUm5Ax44YgBX2dT3KNhkRCaLMqb7TV4\n0LkV5JTNds9kd9Iz4aAHYpyBNgEkvfDomNy3p3Faa5LKBq+8KhUBIcssPmGvrt9H\nAojRAVsoeH9dC2e+9xb/L1KqGQZ4Pns9o1ndUlECgYEAqFH1mQzaKJQZ6mtlvlMS\nhymVJikfsC8sJl1ye3KZmLIu7PqfDfPSMca1ju/71f2h2xvjO4QdDuFLheeQLgzO\nahsqwfGGRSOJZth6OPUdb6Ae+fAt/MqM2uy0H4TOFKg43c0UTolUUXHUsw+1EwMd\nOWhTFLztsq4IX4G61SM3MOQ=\n-----END PRIVATE KEY-----\n", - "client_email": "event-680@shoper-app.iam.gserviceaccount.com", - "client_id": "112632098951209020092", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/event-680%40shoper-app.iam.gserviceaccount.com", - "universe_domain": "googleapis.com" -} From 59594974e7c295111f0c4b4f267a07c52d1d5dda Mon Sep 17 00:00:00 2001 From: joshijhanvi Date: Thu, 19 Oct 2023 16:16:02 +0530 Subject: [PATCH 3/6] feat: Implementation of database to connect with db, create model and changes in readme file. --- README.md | 195 ++++++++++++++++++++++++-------- apps/__init__.py | 18 ++- apps/api/auth/models.py | 25 ++++ apps/api/auth/schema.py | 24 ++++ apps/api/auth/view.py | 47 +++++--- apps/constant/constant.py | 7 +- config/env_config.py | 2 +- github/PULL_REQUEST_TEMPLATE.md | 52 +++++++++ images/python_logo.png | Bin 0 -> 51122 bytes requirements.txt | 111 +++++++++++++++--- 10 files changed, 398 insertions(+), 83 deletions(-) create mode 100644 apps/api/auth/models.py create mode 100644 apps/api/auth/schema.py create mode 100644 github/PULL_REQUEST_TEMPLATE.md create mode 100644 images/python_logo.png diff --git a/README.md b/README.md index e26123e..aa7ab3c 100644 --- a/README.md +++ b/README.md @@ -1,76 +1,175 @@ -# FastAPI boilerplate + +
+
+ Logo -## 🛠 Skills -Python, Fask-API, Swagger Doc, Html and Java Scripts. +

Python Fast API boilerplate

-## Install + configure the project +

+ Fast API boiler plate project +

+
-### 1. Linux -``` -# Create python virtual environment -python3 -m venv venv -# Activate the python virtual environment -source venv/bin/activate -# Install the requirements for the project into the virtual environment -pip install -r requirements.txt + +
+ Table of Contents +
    +
  1. + About The Project + +
  2. +
  3. + Getting Started + +
  4. +
  5. License
  6. +
+
-# Install the dependencies of Fast API -pip install "fastapi[all]" + +## About The Project -# Upgrade pip version -python -m pip install --upgrade pip==22.1.2 -``` -### 2. Windows -``` -# Create python virtual environment -conda create --name venv python=3.10.12 +FastAPI boilerplate provides a simple basic structure for project creation with mysql database. -# Activate the python virtual environment -conda activate venv -# Install the requirements for the project into the virtual environment in the following sequence: -pip install -r requirements.txt +### Built With -# Install the dependencies of Fast API -pip install "fastapi[all]" +* [![Python][Python]][Python-url] +* [![FastAPI][FastAPI]][FastAPI-url] -# Upgrade pip version -python -m pip install --upgrade pip==22.1.2 -``` + +## Getting Started -## Use the alembic to Upgrade/Downgrade the database in the FastAPI -Note: Because by default Fastapi is provide only initial migrations. It doesn't support the upgrade and downgrade the database. -so,to perform automatic migrations follow the following steps: +Instructions for setting up project locally. +To get a local copy up and running follow these simple steps. -1. # To create Migration folder -python -m alembic init migrations +## Install + configure the project -2. ## update the Migrations>>env.py file o auto migrate the database. -from models import Base -target_database = Base.metadata +### 1. Linux +### Prerequisites + +Requirement of Project +* Install Python + ```sh + Python-Version : 3.10.13 + ``` +* Create python virtual environment + ```sh + python3 -m venv venv + ``` +* Activate the python virtual environment + ```sh + source venv/bin/activate + ``` + +### Installation + +1. Clone the repo + ```sh + git clone https://github.com/viitoradmin/python-fastapi-boilerplate + ``` +2. Upgrade pip version + ```sh + python -m pip install --upgrade pip==22.1.2 + ``` +3. Install the requirements for the project into the virtual environment + ```sh + pip install -r requirements.txt + ``` +4. Install the dependencies of Fast API + ```sh + pip install "fastapi[all]" + ``` -4. # Perform the initial migrations -alembic revision --autogenerate -m 'initials' +### 2. Windows -5. # Apply the changes into the database (upgrade the database) -alembic upgrade head - # To downgrade the database if required - alembic downgrade -1 +1. Create python virtual environment + ``` + conda create --name venv python=3.10.12 + ``` + +2. Activate the python virtual environment + ``` + conda activate venv + ``` + +3. Install the requirements for the project into the virtual environment in the following sequence: + ``` + pip install -r requirements.txt + ``` + +4. Install the dependencies of Fast API + ``` + pip install "fastapi[all]" + ``` + +5. Upgrade pip version + ``` + python -m pip install --upgrade pip==22.1.2 + ``` + +### Use the alembic to Upgrade/Downgrade the database in the FastAPI + Note: Because by default Fastapi is provide only initial migrations. + It doesn't support the upgrade and downgrade the database. + so,to perform automatic migrations follow the following steps: + + +1. To create Migration folder + ``` + python -m alembic init migrations + ``` +2. Update the sqlalchemy.url into alembic.ini file + ``` + sqlalchemy.url = mysql+pymysql://user:pass@host/db_name + ``` + +3. update the Migrations>>env.py file o auto migrate the database. + ``` + from models import Base + target_database = Base.metadata + ``` + +4. Perform the initial migrations + ``` + alembic revision --autogenerate -m 'initials' + ``` + +5. Apply the changes into the database (upgrade the database) + ``` + alembic upgrade head + ``` + +6. To downgrade the database if required + ``` + alembic downgrade -1 + ``` ## Run the server in development mode Add environment variables (given in .env) by running following command in cmd/terminal: Run the server -``` -python asgi.py -``` + ``` + python asgi.py + ``` Browse Swagger API Doc at: http://localhost:8000/docs Browse Redoc at: http://localhost:8000/redoc ## Release History * 0.1 - * Work in progress \ No newline at end of file + * Work in progress + + + +[Python]: https://img.shields.io/badge/Python-000000?style=for-the-badge&logo=python&logoColor=Blue +[Python-url]: https://docs.python.org/3.10/ +[FastAPI]: https://img.shields.io/badge/FastAPI-20232A?style=for-the-badge&logo=fastapi&logoColor=009485 +[FastAPI-url]: https://fastapi.tiangolo.com/ \ No newline at end of file diff --git a/apps/__init__.py b/apps/__init__.py index f272f7a..4f89467 100644 --- a/apps/__init__.py +++ b/apps/__init__.py @@ -1,13 +1,27 @@ from config import cors from fastapi import FastAPI +from config import database +from apps.constant import constant from fastapi_versioning import VersionedFastAPI -from apps.api.auth.view import defaultrouter +from apps.api.auth.view import defaultrouter, router +from apps.api.auth.models import Base as authbase + +# Bind with the database, whenever new models find it's create it. +authbase.metadata.create_all(bind=database.engine) # Create app object and add routes app = FastAPI(title="Python FastAPI boilerplate", middleware=cors.middleware) # define router for different version -app.include_router(defaultrouter) # router for version 1 +# router for version 1 +app.include_router( + defaultrouter, + prefix=constant.API_V1, tags=["/v1"] + ) +# router for version 2 +app.include_router( + router, prefix=constant.API_V2, tags=["/v2"] + ) # Define version to specify version related API's. app = VersionedFastAPI(app, version_format="{major}", prefix_format="/v{major}", enable_latest=True) \ No newline at end of file diff --git a/apps/api/auth/models.py b/apps/api/auth/models.py new file mode 100644 index 0000000..0961ce3 --- /dev/null +++ b/apps/api/auth/models.py @@ -0,0 +1,25 @@ +from fastapi import FastAPI +from config.database import Base +from datetime import datetime +from sqlalchemy import Column, String, Integer, DateTime + + +class Users(Base): + """ + Table used for stored the users information + """ + __tablename__ = "users" + + id = Column(Integer, primary_key=True, nullable=False) + uuid = Column(String(100), nullable=False, unique=True, doc='unique_id') + first_name = Column(String(255), doc='First name of user', nullable=True) + last_name = Column(String(255), doc='Last name of user', nullable=True) + email = Column(String(100), doc='Email ID of the user', nullable=True) + username = Column(String(100), doc='Username of the user', nullable=True) + password = Column(String(100), doc='Password of the user', nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False, + doc='its generate automatically when data create') + updated_at = Column(DateTime, nullable=True, + onupdate=datetime.utcnow, doc='its generate automatically when data update') + deleted_at = Column(DateTime, nullable=True, + doc='its generate automatically when data deleted') \ No newline at end of file diff --git a/apps/api/auth/schema.py b/apps/api/auth/schema.py new file mode 100644 index 0000000..98643d1 --- /dev/null +++ b/apps/api/auth/schema.py @@ -0,0 +1,24 @@ +"""This module is for swager and request parameter schema""" +from pydantic import BaseModel, Extra + + +class UserAuth(BaseModel): + first_name: str + last_name: str + email: str + password: str + username: str + + class Config: + extra = Extra.forbid + orm_mode = True + extra = Extra.allow + schema_extra = { + "example": { + "first_name": "John", + "last_name": "Smith", + "email": "jhohnsmith@example.com", + "password": "Abc@123", + "username": "Jhon123" + } + } \ No newline at end of file diff --git a/apps/api/auth/view.py b/apps/api/auth/view.py index d51454c..c18ce70 100644 --- a/apps/api/auth/view.py +++ b/apps/api/auth/view.py @@ -1,5 +1,9 @@ from fastapi import status +from config import database +from fastapi import Depends +from sqlalchemy.orm import Session from fastapi_utils.cbv import cbv +from apps.api.auth import schema from fastapi import APIRouter from apps.constant import constant from fastapi_versioning import version @@ -8,37 +12,54 @@ ## Load API's defaultrouter = APIRouter() +router = APIRouter() +getdb = database.get_db -## Define API's here +## Define verison 1 API's here @cbv(defaultrouter) class UserCrudApi(): """This class is for user's CRUD operation with version 1 API's""" - @defaultrouter.get('/v1/list/user') + @defaultrouter.get('/list/user') @version(1) async def list_user(self): """This API is for list user. - """ + Args: None + Returns: + response: will return list.""" try: - data = "Hello there, welcome to fastapi bolierplate" - return data + data = {"List:" : "Hello there, welcome to fastapi bolierplate"} + return StandardResponse(True, status.HTTP_200_OK, data, constant.STATUS_SUCCESS) except Exception as e: return StandardResponse(False, status.HTTP_400_BAD_REQUEST, None, constant.ERROR_MSG) - @defaultrouter.post('/v1/create/user') + @defaultrouter.post('/create/user') @version(1) - async def list_user(self): - """This API is for list user. - """ + async def create_user(self, body: schema.UserAuth, + db: Session = Depends(getdb)): + """This API is for create user. + Args: + body(dict) : user's data + Returns: + response: will return the user's data""" try: - data = "Hello there, welcome to fastapi bolierplate" - return data + data = body.dict() + return StandardResponse(True, status.HTTP_200_OK, data, constant.STATUS_SUCCESS) except Exception as e: return StandardResponse(False, status.HTTP_400_BAD_REQUEST, None, constant.ERROR_MSG) - - @defaultrouter.get("/v2/list") + + +## Define version 2 API's here +@cbv(router) +class UserVersionApi(): + @router.get("/list") @version(2) async def get_list(self): + """ This API will list version 2 Api's + Args: None + Returns: + response: list + """ try: response = { "data": "User's list data" } return StandardResponse(True, status.HTTP_200_OK, response, constant.STATUS_SUCCESS) diff --git a/apps/constant/constant.py b/apps/constant/constant.py index 0ea7462..5178a72 100644 --- a/apps/constant/constant.py +++ b/apps/constant/constant.py @@ -4,8 +4,8 @@ STATUS_NULL = None STATUS_TRUE = True STATUS_FALSE = False -API_V1 = "v1" -API_V2 = "v2" +API_V1 = "/v1" +API_V2 = "/v2" ## success message ## USER_CREATED = "User created successfully!" @@ -15,4 +15,5 @@ ## error message ## -ERROR_MSG = "Error while creating user!" \ No newline at end of file +ERROR_MSG = "Error while creating user!" +LIST_ERROR_MSG = "There is no list to retrieve!!" \ No newline at end of file diff --git a/config/env_config.py b/config/env_config.py index cd05821..37548ce 100644 --- a/config/env_config.py +++ b/config/env_config.py @@ -1,7 +1,7 @@ +import os from dotenv import load_dotenv from os.path import join from .project_path import BASE_DIR -import os ##### ENV configuration ##### dotenv_path = join(BASE_DIR, ".env") diff --git a/github/PULL_REQUEST_TEMPLATE.md b/github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..5694a6e --- /dev/null +++ b/github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,52 @@ +### Description +Please provide a clear and concise description of the changes in this pull request. Include any relevant context or motivation for the changes. + +### Jira Link(Task, Bug, Story) +- [Link to the Jira link (if applicable)]() + +### Type of Change +Please select the appropriate type of change: +- [ ] Bug Fix +- [ ] New Feature +- [ ] Code style update (Formatting, Local Variables) +- [ ] Enhancement +- [ ] Refactoring +- [ ] Documentation Update +- [ ] README.md Update +- [ ] Other (please specify) + +### Database Changes (if applicable) +Please describe any changes made to the database schema or data model in this pull request. Include relevant migration scripts or steps. + +### New Packages (if applicable) +Please list any new package dependencies added in this pull request along with their purpose and version. + +- [ ] Package 1 (Version x.x.x): Purpose of the package. +- [ ] Package 2 (Version x.x.x): Purpose of the package. +- [ ] ... + +### Bug Fix Details (if applicable) +If this pull request addresses a bug, please provide details about the bug, steps to reproduce, and the expected behavior. + +### New Feature Details (if applicable) +If this pull request introduces a new feature, describe the feature's purpose and any relevant design decisions. + +### Checklist +Please check the following before submitting your pull request: + +- [ ] I have tested my changes locally and verified that they work as expected. +- [ ] I have added or updated tests to cover the changes (if applicable). +- [ ] I have updated the documentation to reflect the changes (if applicable). +- [ ] I have rebased my branch on the latest main/master to ensure a clean merge. +- [ ] I have reviewed my code for any potential issues and resolved them. + +### Screenshots (if applicable) +If the changes include any user interface (UI) modifications, please add screenshots to illustrate the changes. + +### Additional Notes (if any) +Add any additional notes or information that may be helpful for reviewers. + +### Reviewers +- [ ] @reviewer1 +- [ ] @reviewer2 +- [ ] @reviewer3 \ No newline at end of file diff --git a/images/python_logo.png b/images/python_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..5a7c3110b65dfbf18435c30596e7ff5d7447ea88 GIT binary patch literal 51122 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%0A^PRj5(ye&}@Gk9Y@?ekgVxD{x-=(MKZ|A07 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 L>WUS&Edu`+-)6!0 literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt index 81877b2..9bcca88 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,18 +1,97 @@ - +aiohttp==3.8.6 +aiosignal==1.3.1 +aniso8601==9.0.1 +annotated-types==0.6.0 +anyio==3.7.1 +async-timeout==4.0.3 +attrs==23.1.0 +bcrypt==4.0.1 +blinker==1.6.2 +boto3==1.28.62 +botocore==1.31.62 +cachetools==5.3.1 +certifi==2023.7.22 +charset-normalizer==3.3.0 +click==8.1.7 +colorama==0.4.6 +dataclasses-json==0.6.1 +decorator==4.4.2 +dnspython==2.4.2 +exceptiongroup==1.1.3 fastapi==0.85.1 +fastapi-utils==0.2.1 +fastapi-versioning==0.10.0 +filelock==3.12.4 +Flask==2.3.2 +Flask-RESTful==0.3.10 +frozenlist==1.4.0 +fsspec==2023.9.2 +greenlet==3.0.0 +h11==0.14.0 +httplib2==0.22.0 +huggingface-hub==0.17.3 +idna==3.4 +imageio==2.31.1 +imageio-ffmpeg==0.4.8 +InstructorEmbedding==1.0.1 +itsdangerous==2.1.2 +Jinja2==3.1.2 +jmespath==1.0.1 +joblib==1.3.2 +jsonpatch==1.33 +jsonpointer==2.4 +langchain==0.0.311 +langsmith==0.0.43 +Mako==1.2.4 +MarkupSafe==2.1.3 +marshmallow==3.20.1 +moviepy==1.0.3 +mpmath==1.3.0 +multidict==6.0.4 +mypy-extensions==1.0.0 +networkx==3.1 +nltk==3.8.1 +numpy==1.26.0 +openai==0.28.1 +packaging==23.1 +passlib==1.7.4 +pgvector==0.2.3 +Pillow==10.0.1 +proglog==0.1.10 +protobuf==4.24.4 +psycopg2-binary==2.9.9 +pyasn1==0.5.0 +pyasn1-modules==0.3.0 +pydantic==1.10.13 +pydantic_core==2.10.1 +PyMySQL==1.1.0 +pyparsing==3.1.1 +python-dateutil==2.8.2 +python-dotenv==1.0.0 +python-ffmpeg==2.0.4 +python-multipart==0.0.6 +pytz==2023.3 +PyYAML==6.0.1 +redis==5.0.0 +regex==2023.10.3 +requests==2.31.0 +rq==1.15.1 +rsa==4.9 +sentence-transformers==2.2.2 +sentencepiece==0.1.99 +six==1.16.0 +sniffio==1.3.0 +SQLAlchemy==1.4.49 +starlette==0.20.4 +sympy==1.12 +tenacity==8.2.3 +threadpoolctl==3.2.0 +tqdm==4.66.1 +transformers==4.34.0 +typing-inspect==0.9.0 +typing_extensions==4.8.0 +uritemplate==4.1.1 +urllib3==2.0.6 uvicorn==0.23.2 -# fastapi["all"] -# annotated-types==0.5.0 -# python-dotenv==0.21.0 -# anyio==3.7.1 -# click==8.1.7 -# colorama==0.4.6 -# exceptiongroup==1.1.3 - -# h11==0.14.0 -# idna==3.4 -# pydantic==1.10.2 -# pydantic_core==2.6.1 -# sniffio==1.3.0 -# typing_extensions==4.7.1 - +Werkzeug==2.3.6 +yarl==1.9.2 From e22c0dd5f934d860ab902fdc95c72c8a5eea950e Mon Sep 17 00:00:00 2001 From: joshijhanvi Date: Fri, 1 Dec 2023 13:32:02 +0530 Subject: [PATCH 4/6] feat: upgrade pydantic versin with python version and made changes for pydantic syntax --- README.md | 11 +++- apps/__init__.py | 11 ++-- apps/api/auth/method.py | 20 ++++++ apps/api/auth/schema.py | 41 +++++++++---- apps/api/auth/service.py | 105 +++++++++++++++++++++++++++++--- apps/api/auth/view.py | 34 ++++++----- apps/api/core/db_methods.py | 36 +++++++++++ apps/api/core/validation.py | 32 ++++++++++ apps/utils/message.py | 14 ++++- apps/utils/standard_response.py | 2 +- github/PULL_REQUEST_TEMPLATE.md | 52 ---------------- requirements.txt | 102 +++++-------------------------- 12 files changed, 273 insertions(+), 187 deletions(-) create mode 100644 apps/api/core/db_methods.py create mode 100644 apps/api/core/validation.py delete mode 100644 github/PULL_REQUEST_TEMPLATE.md diff --git a/README.md b/README.md index aa7ab3c..2dbbbe6 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ To get a local copy up and running follow these simple steps. Requirement of Project * Install Python ```sh - Python-Version : 3.10.13 + Python-Version : 3.11.0 ``` * Create python virtual environment ```sh @@ -73,7 +73,7 @@ Requirement of Project 1. Clone the repo ```sh - git clone https://github.com/viitoradmin/python-fastapi-boilerplate + git clone https://github.com/viitoradmin/python-fastapi-boilerplate/tree/feature/fastapi ``` 2. Upgrade pip version ```sh @@ -92,7 +92,7 @@ Requirement of Project 1. Create python virtual environment ``` - conda create --name venv python=3.10.12 + conda create --name venv python=3.11 ``` 2. Activate the python virtual environment @@ -160,8 +160,13 @@ Run the server python asgi.py ``` Browse Swagger API Doc at: http://localhost:8000/docs + Browse Redoc at: http://localhost:8000/redoc +Browse Swagger API Doc for version v1 at: http://localhost:8000/v1/docs + +Browse Swagger API Doc for version v2 at: http://localhost:8000/v2/docs + ## Release History * 0.1 diff --git a/apps/__init__.py b/apps/__init__.py index 4f89467..6b19803 100644 --- a/apps/__init__.py +++ b/apps/__init__.py @@ -3,7 +3,7 @@ from config import database from apps.constant import constant from fastapi_versioning import VersionedFastAPI -from apps.api.auth.view import defaultrouter, router +from apps.api.auth.view import router_v1, router_v2 from apps.api.auth.models import Base as authbase # Bind with the database, whenever new models find it's create it. @@ -15,13 +15,14 @@ # define router for different version # router for version 1 app.include_router( - defaultrouter, + router_v1, prefix=constant.API_V1, tags=["/v1"] ) # router for version 2 app.include_router( - router, prefix=constant.API_V2, tags=["/v2"] - ) + router_v2, + prefix=constant.API_V2, tags=["/v2"] + ) # Define version to specify version related API's. -app = VersionedFastAPI(app, version_format="{major}", prefix_format="/v{major}", enable_latest=True) \ No newline at end of file +app = VersionedFastAPI(app, version_format="{major}", prefix_format="/v{major}") \ No newline at end of file diff --git a/apps/api/auth/method.py b/apps/api/auth/method.py index e69de29..a40dac2 100644 --- a/apps/api/auth/method.py +++ b/apps/api/auth/method.py @@ -0,0 +1,20 @@ +from sqlalchemy.orm import Session +from apps.constant import constant + +class UserAuthMethod(): + """This class defines methods to authenticate users.""" + + def __init__(self, model) -> constant.STATUS_NULL: + self.model = model + + def find_by_email(self, db: Session, email: str): + """This funtion will return the email object""" + return db.query(self.model).filter( + self.model.email == email + ).first() + + def find_by_username(self, db: Session, username: str): + """This function will return the username object""" + return db.query(self.model).filter( + self.model.username == username + ).first() \ No newline at end of file diff --git a/apps/api/auth/schema.py b/apps/api/auth/schema.py index 98643d1..9b669a2 100644 --- a/apps/api/auth/schema.py +++ b/apps/api/auth/schema.py @@ -1,19 +1,18 @@ """This module is for swager and request parameter schema""" -from pydantic import BaseModel, Extra +from pydantic import BaseModel, Extra, validator, Field +from apps.api.core import validation class UserAuth(BaseModel): - first_name: str - last_name: str - email: str - password: str - username: str + first_name: str + last_name: str + email: str + password: str + username: str class Config: - extra = Extra.forbid - orm_mode = True - extra = Extra.allow - schema_extra = { + from_attributes = True + json_schema_extra = { "example": { "first_name": "John", "last_name": "Smith", @@ -21,4 +20,24 @@ class Config: "password": "Abc@123", "username": "Jhon123" } - } \ No newline at end of file + } + + @validator('first_name', pre=True) + def first_name_must_be_required(cls, v): + return validation.ValidationMethods().not_null_validator(v, 'first_name') + + @validator('last_name', allow_reuse=True) + def last_name_must_be_required(cls, v): + return validation.ValidationMethods().not_null_validator(v, 'last_name') + + @validator('email', allow_reuse=True) + def email_must_be_required(cls, v): + return validation.ValidationMethods().not_null_validator(v, 'email') + + @validator('username', allow_reuse=True) + def username_must_be_required(cls, v): + return validation.ValidationMethods().not_null_validator(v, 'username') + + @validator('email', allow_reuse=True) + def email_field_validator(cls, v): + return validation.ValidationMethods().email_validator(v) \ No newline at end of file diff --git a/apps/api/auth/service.py b/apps/api/auth/service.py index 572e598..434da08 100644 --- a/apps/api/auth/service.py +++ b/apps/api/auth/service.py @@ -1,15 +1,102 @@ -from apps.utils.standard_response import StandardResponse +import uuid from fastapi import status from apps.constant import constant +from sqlalchemy.orm import Session +from apps.api.auth.models import Users +from apps.api.core import db_methods +from fastapi.encoders import jsonable_encoder +from apps.api.auth.method import UserAuthMethod +from apps.api.core.validation import ValidationMethods +from apps.utils.message import ErrorMessage, InfoMessage +from apps.utils.standard_response import StandardResponse + -def get_str_name(name: str): - if name is not None: +class UserAuthService: + """This class represents the user creation service""" + def create_user_service(self, db: Session, body: dict): + """This function is used to create user + Args: + db (Session): database connection + body (dict): dictionary to user information data + + Returns: + response (dict): user object representing the user + """ + username = body['username'] + email = body['email'] or None + password = body['password'] + + # check Email exists in body or not + if "email" not in body or not email: + return StandardResponse( + False, status.HTTP_400_BAD_REQUEST, None, ErrorMessage.emailRequired + ) + + # check Email exists in Db or not + if (user_object := UserAuthMethod(Users).find_by_email(db, email)): + return StandardResponse( + False, status.HTTP_400_BAD_REQUEST, + constant.STATUS_NULL, ErrorMessage.emailAlreadyExist + ).make + + # For password validation + if not ValidationMethods().password_validator(password): + return StandardResponse( + False, status.HTTP_400_BAD_REQUEST, + constant.STATUS_NULL, ErrorMessage.invalidPasswordFormat + ).make + + user_object = Users( + uuid = uuid.uuid4(), + first_name=body['first_name'], + last_name=body['last_name'], + username=username, + email=email, + password=password + ) + + # Store user object in database + if not(user_save := db_methods.BaseMethods(Users).save(user_object, db)): + return StandardResponse( + False, status.HTTP_400_BAD_REQUEST, + constant.STATUS_NULL, ErrorMessage.userNotSaved + ) + + # check email exists or not + if user_object := UserAuthMethod(Users).find_by_email(db, email): + data = { + "username": user_object.username, + "first_name": user_object.first_name, + "last_name": user_object.last_name, + "email": user_object.email, + "password": user_object.password, + } + + else: + return StandardResponse( + False, status.HTTP_400_BAD_REQUEST, + constant.STATUS_NULL, ErrorMessage.userInvalid + ).make + return StandardResponse( - True, status.HTTP_200_OK, {"success": "Welcome"}, - constant.STATUS_SUCCESS + True, status.HTTP_200_OK, data, InfoMessage.userCreated + ).make + + def get_user_service(self, db): + """This function returns the user service list.""" + if not (user_object := db_methods.BaseMethods(Users).find_by_uuid(db)): + return StandardResponse( + False, + status.HTTP_400_BAD_REQUEST, + None, + ErrorMessage.userNotFound ) - else: + # convert the object data into json + user_data = jsonable_encoder(user_object) + return StandardResponse( - True, status.HTTP_400_BAD_REQUEST, {"success": "Error"}, - constant.STATUS_ERROR - ) \ No newline at end of file + True, + status.HTTP_200_OK, + user_data, + InfoMessage.retriveInfoSuccessfully + ) \ No newline at end of file diff --git a/apps/api/auth/view.py b/apps/api/auth/view.py index c18ce70..6bfc495 100644 --- a/apps/api/auth/view.py +++ b/apps/api/auth/view.py @@ -2,40 +2,38 @@ from config import database from fastapi import Depends from sqlalchemy.orm import Session -from fastapi_utils.cbv import cbv from apps.api.auth import schema from fastapi import APIRouter from apps.constant import constant from fastapi_versioning import version -from fastapi_utils.inferring_router import InferringRouter +from apps.api.auth.service import UserAuthService from apps.utils.standard_response import StandardResponse ## Load API's -defaultrouter = APIRouter() router = APIRouter() +router_v1 = APIRouter() getdb = database.get_db ## Define verison 1 API's here -@cbv(defaultrouter) class UserCrudApi(): """This class is for user's CRUD operation with version 1 API's""" - @defaultrouter.get('/list/user') + @router_v1.get('/users') @version(1) - async def list_user(self): + async def list_user(db: Session = Depends(getdb)): """This API is for list user. Args: None Returns: - response: will return list.""" + response: will return users list.""" try: - data = {"List:" : "Hello there, welcome to fastapi bolierplate"} - return StandardResponse(True, status.HTTP_200_OK, data, constant.STATUS_SUCCESS) + response = UserAuthService().get_user_service(db) + return response except Exception as e: return StandardResponse(False, status.HTTP_400_BAD_REQUEST, None, constant.ERROR_MSG) - @defaultrouter.post('/create/user') + @router_v1.post('/create/user') @version(1) - async def create_user(self, body: schema.UserAuth, + async def create_user(body: schema.UserAuth, db: Session = Depends(getdb)): """This API is for create user. Args: @@ -43,18 +41,22 @@ async def create_user(self, body: schema.UserAuth, Returns: response: will return the user's data""" try: - data = body.dict() - return StandardResponse(True, status.HTTP_200_OK, data, constant.STATUS_SUCCESS) + # as per pydantic version 2. + body = body.model_dump() + response = UserAuthService().create_user_service(db, body) + return response except Exception as e: return StandardResponse(False, status.HTTP_400_BAD_REQUEST, None, constant.ERROR_MSG) ## Define version 2 API's here -@cbv(router) +router_v2 = APIRouter() + class UserVersionApi(): - @router.get("/list") + """This class provides version 2 API's for users""" + @router_v2.get("/list") @version(2) - async def get_list(self): + async def get_list(): """ This API will list version 2 Api's Args: None Returns: diff --git a/apps/api/core/db_methods.py b/apps/api/core/db_methods.py new file mode 100644 index 0000000..966af01 --- /dev/null +++ b/apps/api/core/db_methods.py @@ -0,0 +1,36 @@ +from fastapi import Depends +from config import database +from apps.constant import constant +from sqlalchemy.orm import Session + +getdb = database.get_db + +class BaseMethods(): + """This class provides basic DB methods""" + + def __init__(self, model): + self.model = model + + def save(self, validate_data, db: Session = Depends(getdb)): + """this function saves the object to the database for the given model + Args: + validate_data (dict): validate data + db (Session): database session. + Returns: + returns the created object + """ + try: + db.add(validate_data) + db.commit() + db.refresh(validate_data) + return constant.STATUS_TRUE + except Exception as err: + print(err) + db.rollback() + db.close() + return constant.STATUS_FALSE + + def find_by_uuid(self, db: Session = Depends(getdb)): + """This function is used to find users.""" + return db.query(self.model).filter(self.model.deleted_at == None).all() + \ No newline at end of file diff --git a/apps/api/core/validation.py b/apps/api/core/validation.py new file mode 100644 index 0000000..8816fb0 --- /dev/null +++ b/apps/api/core/validation.py @@ -0,0 +1,32 @@ +import re + +class ValidationMethods: + + def not_null_validator(self, v, field): + if v == '': + raise ValueError(f'{field} must be required') + return v + + def password_validator(self, v): + reg = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!#%*?&]{6,20}$" + pattern = re.compile(reg) + + if not(match := re.search(pattern, v)): + """Length should be at least 6 + Length should be not be greater than 20 + Password should have at least one numeral + Password should have at least one uppercase letter + Password should have at least one lowercase letter + Password should have at least one of the symbols $@# + """ + return False + return True + + def email_validator(self, v): + # regular expression for email validation + reg = "([-!#-'*+/-9=?A-Z^-~]+(\.[-!#-'*+/-9=?A-Z^-~]+)*|\"([]!#-[^-~ \t]|(\\[\t -~]))+\")@([-!#-'*+/-9=?A-Z^-~]+(\.[-!#-'*+/-9=?A-Z^-~]+)*|\[[\t -Z^-~]*])" + + pattern = re.compile(reg) + if not(match := re.search(pattern, v)): + raise ValueError("Invalid Email format") + return v \ No newline at end of file diff --git a/apps/utils/message.py b/apps/utils/message.py index 6d57d9e..8aadd34 100644 --- a/apps/utils/message.py +++ b/apps/utils/message.py @@ -1,6 +1,16 @@ # predefined messages. +class InfoMessage: + userCreated = "User created successfully" + retriveInfoSuccessfully = "Retrive user info successfully" + class ErrorMessage: - USER_NOT_SAVED = "User is not saved" + emailRequired = "Email is required" + usernameRequired = "Username is required" + usernameAlreadyExists = "Username already exists" + userNotSaved = "User is not saved" emailRequired = "E-Mail is required" - emailAlreadyExist = "E-Mail already exists." \ No newline at end of file + emailAlreadyExist = "E-Mail already exists." + invalidPasswordFormat = "Password format is invalid" + userNotFound = "User is not found" + userInvalid = "user is invalid" \ No newline at end of file diff --git a/apps/utils/standard_response.py b/apps/utils/standard_response.py index 11ef385..cce6187 100644 --- a/apps/utils/standard_response.py +++ b/apps/utils/standard_response.py @@ -29,6 +29,6 @@ def __init__(self, status, status_code: int, data: dict, message: str) -> None: @property def make(self) -> dict: - self.status = constant.STATUS_SUCCESS if self.status in [201, 200] else constant.STATUS_FAIL + self.status = constant.STATUS_SUCCESS if self.status_code in [201, 200] else constant.STATUS_FAIL response = {'status': self.status, 'data': self.data, 'message': self.message} return JSONResponse(content=response, status_code=self.status_code) \ No newline at end of file diff --git a/github/PULL_REQUEST_TEMPLATE.md b/github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 5694a6e..0000000 --- a/github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,52 +0,0 @@ -### Description -Please provide a clear and concise description of the changes in this pull request. Include any relevant context or motivation for the changes. - -### Jira Link(Task, Bug, Story) -- [Link to the Jira link (if applicable)]() - -### Type of Change -Please select the appropriate type of change: -- [ ] Bug Fix -- [ ] New Feature -- [ ] Code style update (Formatting, Local Variables) -- [ ] Enhancement -- [ ] Refactoring -- [ ] Documentation Update -- [ ] README.md Update -- [ ] Other (please specify) - -### Database Changes (if applicable) -Please describe any changes made to the database schema or data model in this pull request. Include relevant migration scripts or steps. - -### New Packages (if applicable) -Please list any new package dependencies added in this pull request along with their purpose and version. - -- [ ] Package 1 (Version x.x.x): Purpose of the package. -- [ ] Package 2 (Version x.x.x): Purpose of the package. -- [ ] ... - -### Bug Fix Details (if applicable) -If this pull request addresses a bug, please provide details about the bug, steps to reproduce, and the expected behavior. - -### New Feature Details (if applicable) -If this pull request introduces a new feature, describe the feature's purpose and any relevant design decisions. - -### Checklist -Please check the following before submitting your pull request: - -- [ ] I have tested my changes locally and verified that they work as expected. -- [ ] I have added or updated tests to cover the changes (if applicable). -- [ ] I have updated the documentation to reflect the changes (if applicable). -- [ ] I have rebased my branch on the latest main/master to ensure a clean merge. -- [ ] I have reviewed my code for any potential issues and resolved them. - -### Screenshots (if applicable) -If the changes include any user interface (UI) modifications, please add screenshots to illustrate the changes. - -### Additional Notes (if any) -Add any additional notes or information that may be helpful for reviewers. - -### Reviewers -- [ ] @reviewer1 -- [ ] @reviewer2 -- [ ] @reviewer3 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 9bcca88..1596377 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,97 +1,23 @@ -aiohttp==3.8.6 -aiosignal==1.3.1 -aniso8601==9.0.1 -annotated-types==0.6.0 anyio==3.7.1 -async-timeout==4.0.3 -attrs==23.1.0 -bcrypt==4.0.1 -blinker==1.6.2 -boto3==1.28.62 -botocore==1.31.62 -cachetools==5.3.1 -certifi==2023.7.22 -charset-normalizer==3.3.0 click==8.1.7 colorama==0.4.6 -dataclasses-json==0.6.1 -decorator==4.4.2 -dnspython==2.4.2 exceptiongroup==1.1.3 -fastapi==0.85.1 -fastapi-utils==0.2.1 -fastapi-versioning==0.10.0 -filelock==3.12.4 -Flask==2.3.2 -Flask-RESTful==0.3.10 -frozenlist==1.4.0 -fsspec==2023.9.2 -greenlet==3.0.0 h11==0.14.0 -httplib2==0.22.0 -huggingface-hub==0.17.3 idna==3.4 -imageio==2.31.1 -imageio-ffmpeg==0.4.8 -InstructorEmbedding==1.0.1 -itsdangerous==2.1.2 -Jinja2==3.1.2 -jmespath==1.0.1 -joblib==1.3.2 -jsonpatch==1.33 -jsonpointer==2.4 -langchain==0.0.311 -langsmith==0.0.43 -Mako==1.2.4 -MarkupSafe==2.1.3 -marshmallow==3.20.1 -moviepy==1.0.3 -mpmath==1.3.0 -multidict==6.0.4 -mypy-extensions==1.0.0 -networkx==3.1 -nltk==3.8.1 -numpy==1.26.0 -openai==0.28.1 -packaging==23.1 -passlib==1.7.4 -pgvector==0.2.3 -Pillow==10.0.1 -proglog==0.1.10 -protobuf==4.24.4 -psycopg2-binary==2.9.9 -pyasn1==0.5.0 -pyasn1-modules==0.3.0 -pydantic==1.10.13 -pydantic_core==2.10.1 -PyMySQL==1.1.0 -pyparsing==3.1.1 -python-dateutil==2.8.2 -python-dotenv==1.0.0 -python-ffmpeg==2.0.4 -python-multipart==0.0.6 -pytz==2023.3 -PyYAML==6.0.1 -redis==5.0.0 -regex==2023.10.3 -requests==2.31.0 -rq==1.15.1 -rsa==4.9 -sentence-transformers==2.2.2 -sentencepiece==0.1.99 -six==1.16.0 sniffio==1.3.0 -SQLAlchemy==1.4.49 -starlette==0.20.4 -sympy==1.12 -tenacity==8.2.3 -threadpoolctl==3.2.0 -tqdm==4.66.1 -transformers==4.34.0 -typing-inspect==0.9.0 typing_extensions==4.8.0 -uritemplate==4.1.1 -urllib3==2.0.6 uvicorn==0.23.2 -Werkzeug==2.3.6 -yarl==1.9.2 +annotated-types==0.6.0 +fastapi==0.104.1 +fastapi-versioning==0.10.0 +greenlet==3.0.1 +pip==23.2.1 +pydantic==2.5.2 +pydantic_core==2.14.5 +PyMySQL==1.1.0 +python-dotenv==1.0.0 +setuptools==68.0.0 +SQLAlchemy==1.4.50 +starlette==0.27.0 +typing_extensions==4.8.0 +wheel==0.38.4 From 3f8b3f6c064c062dc241255e6568af33642cdb43 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 30 May 2024 18:26:00 +0530 Subject: [PATCH 5/6] fix: Modify code and refactor it. --- apps/__init__.py | 24 ++++++------------ apps/api/auth/method.py | 11 +++++--- apps/api/auth/models.py | 10 +++++--- apps/api/auth/schema.py | 35 ++++++------------------- apps/api/auth/service.py | 37 ++++++++++++++------------- apps/api/auth/v1/view.py | 0 apps/api/auth/v2/view.py | 0 apps/api/auth/view.py | 45 ++++++++------------------------- apps/api/core/db_methods.py | 15 ++++++----- apps/api/core/validation.py | 35 +++++++++++++------------ apps/constant/constant.py | 4 ++- apps/utils/helper.py | 7 ++--- apps/utils/message.py | 6 +++-- apps/utils/standard_response.py | 5 +++- asgi.py | 5 +++- config/cors.py | 4 +-- config/database.py | 6 ++--- config/env_config.py | 9 +++---- config/project_path.py | 1 + requirements.txt | 27 +++----------------- 20 files changed, 120 insertions(+), 166 deletions(-) delete mode 100644 apps/api/auth/v1/view.py delete mode 100644 apps/api/auth/v2/view.py diff --git a/apps/__init__.py b/apps/__init__.py index 6b19803..491093b 100644 --- a/apps/__init__.py +++ b/apps/__init__.py @@ -1,10 +1,10 @@ -from config import cors +"""This module is include API's route.""" from fastapi import FastAPI -from config import database -from apps.constant import constant -from fastapi_versioning import VersionedFastAPI -from apps.api.auth.view import router_v1, router_v2 + from apps.api.auth.models import Base as authbase +from apps.api.auth.view import router +from apps.constant import constant +from config import cors, database # Bind with the database, whenever new models find it's create it. authbase.metadata.create_all(bind=database.engine) @@ -13,16 +13,8 @@ app = FastAPI(title="Python FastAPI boilerplate", middleware=cors.middleware) # define router for different version -# router for version 1 +# router for API's app.include_router( - router_v1, - prefix=constant.API_V1, tags=["/v1"] + router, + prefix=constant.API_V1 ) -# router for version 2 -app.include_router( - router_v2, - prefix=constant.API_V2, tags=["/v2"] - ) - -# Define version to specify version related API's. -app = VersionedFastAPI(app, version_format="{major}", prefix_format="/v{major}") \ No newline at end of file diff --git a/apps/api/auth/method.py b/apps/api/auth/method.py index a40dac2..96c0fb8 100644 --- a/apps/api/auth/method.py +++ b/apps/api/auth/method.py @@ -1,20 +1,23 @@ +"""This module contains database operations methods.""" from sqlalchemy.orm import Session + from apps.constant import constant + class UserAuthMethod(): """This class defines methods to authenticate users.""" - + def __init__(self, model) -> constant.STATUS_NULL: self.model = model - + def find_by_email(self, db: Session, email: str): """This funtion will return the email object""" return db.query(self.model).filter( self.model.email == email ).first() - + def find_by_username(self, db: Session, username: str): """This function will return the username object""" return db.query(self.model).filter( self.model.username == username - ).first() \ No newline at end of file + ).first() diff --git a/apps/api/auth/models.py b/apps/api/auth/models.py index 0961ce3..8e2af2e 100644 --- a/apps/api/auth/models.py +++ b/apps/api/auth/models.py @@ -1,7 +1,9 @@ -from fastapi import FastAPI -from config.database import Base +"""This module contains database model implementations.""" from datetime import datetime -from sqlalchemy import Column, String, Integer, DateTime + +from sqlalchemy import Column, DateTime, Integer, String + +from config.database import Base class Users(Base): @@ -22,4 +24,4 @@ class Users(Base): updated_at = Column(DateTime, nullable=True, onupdate=datetime.utcnow, doc='its generate automatically when data update') deleted_at = Column(DateTime, nullable=True, - doc='its generate automatically when data deleted') \ No newline at end of file + doc='its generate automatically when data deleted') diff --git a/apps/api/auth/schema.py b/apps/api/auth/schema.py index 9b669a2..8b2e550 100644 --- a/apps/api/auth/schema.py +++ b/apps/api/auth/schema.py @@ -1,16 +1,17 @@ """This module is for swager and request parameter schema""" -from pydantic import BaseModel, Extra, validator, Field -from apps.api.core import validation +from pydantic import BaseModel class UserAuth(BaseModel): - first_name: str - last_name: str - email: str - password: str - username: str + """This class is for user schema.""" + first_name: str + last_name: str + email: str + password: str + username: str class Config: + """This class is the schema for user configuration.""" from_attributes = True json_schema_extra = { "example": { @@ -21,23 +22,3 @@ class Config: "username": "Jhon123" } } - - @validator('first_name', pre=True) - def first_name_must_be_required(cls, v): - return validation.ValidationMethods().not_null_validator(v, 'first_name') - - @validator('last_name', allow_reuse=True) - def last_name_must_be_required(cls, v): - return validation.ValidationMethods().not_null_validator(v, 'last_name') - - @validator('email', allow_reuse=True) - def email_must_be_required(cls, v): - return validation.ValidationMethods().not_null_validator(v, 'email') - - @validator('username', allow_reuse=True) - def username_must_be_required(cls, v): - return validation.ValidationMethods().not_null_validator(v, 'username') - - @validator('email', allow_reuse=True) - def email_field_validator(cls, v): - return validation.ValidationMethods().email_validator(v) \ No newline at end of file diff --git a/apps/api/auth/service.py b/apps/api/auth/service.py index 434da08..c9b2443 100644 --- a/apps/api/auth/service.py +++ b/apps/api/auth/service.py @@ -1,12 +1,15 @@ +"""This module contains API's specific functionality.""" import uuid + from fastapi import status -from apps.constant import constant +from fastapi.encoders import jsonable_encoder from sqlalchemy.orm import Session + +from apps.api.auth.method import UserAuthMethod from apps.api.auth.models import Users from apps.api.core import db_methods -from fastapi.encoders import jsonable_encoder -from apps.api.auth.method import UserAuthMethod from apps.api.core.validation import ValidationMethods +from apps.constant import constant from apps.utils.message import ErrorMessage, InfoMessage from apps.utils.standard_response import StandardResponse @@ -25,27 +28,27 @@ def create_user_service(self, db: Session, body: dict): username = body['username'] email = body['email'] or None password = body['password'] - + # check Email exists in body or not if "email" not in body or not email: return StandardResponse( False, status.HTTP_400_BAD_REQUEST, None, ErrorMessage.emailRequired ) - + # check Email exists in Db or not if (user_object := UserAuthMethod(Users).find_by_email(db, email)): return StandardResponse( False, status.HTTP_400_BAD_REQUEST, constant.STATUS_NULL, ErrorMessage.emailAlreadyExist ).make - + # For password validation if not ValidationMethods().password_validator(password): return StandardResponse( False, status.HTTP_400_BAD_REQUEST, constant.STATUS_NULL, ErrorMessage.invalidPasswordFormat ).make - + user_object = Users( uuid = uuid.uuid4(), first_name=body['first_name'], @@ -54,14 +57,14 @@ def create_user_service(self, db: Session, body: dict): email=email, password=password ) - + # Store user object in database - if not(user_save := db_methods.BaseMethods(Users).save(user_object, db)): + if not(db_methods.BaseMethods(Users).save(user_object, db)): return StandardResponse( False, status.HTTP_400_BAD_REQUEST, constant.STATUS_NULL, ErrorMessage.userNotSaved ) - + # check email exists or not if user_object := UserAuthMethod(Users).find_by_email(db, email): data = { @@ -71,17 +74,17 @@ def create_user_service(self, db: Session, body: dict): "email": user_object.email, "password": user_object.password, } - - else: + + else: return StandardResponse( False, status.HTTP_400_BAD_REQUEST, constant.STATUS_NULL, ErrorMessage.userInvalid ).make - + return StandardResponse( True, status.HTTP_200_OK, data, InfoMessage.userCreated ).make - + def get_user_service(self, db): """This function returns the user service list.""" if not (user_object := db_methods.BaseMethods(Users).find_by_uuid(db)): @@ -93,10 +96,10 @@ def get_user_service(self, db): ) # convert the object data into json user_data = jsonable_encoder(user_object) - + return StandardResponse( - True, + True, status.HTTP_200_OK, user_data, InfoMessage.retriveInfoSuccessfully - ) \ No newline at end of file + ) diff --git a/apps/api/auth/v1/view.py b/apps/api/auth/v1/view.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api/auth/v2/view.py b/apps/api/auth/v2/view.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api/auth/view.py b/apps/api/auth/view.py index 6bfc495..f89df38 100644 --- a/apps/api/auth/view.py +++ b/apps/api/auth/view.py @@ -1,25 +1,21 @@ -from fastapi import status -from config import database -from fastapi import Depends +"""This module is responsible to contain API's endpoint""" +from fastapi import APIRouter, Depends, status from sqlalchemy.orm import Session + from apps.api.auth import schema -from fastapi import APIRouter -from apps.constant import constant -from fastapi_versioning import version from apps.api.auth.service import UserAuthService +from apps.constant import constant from apps.utils.standard_response import StandardResponse +from config import database ## Load API's router = APIRouter() -router_v1 = APIRouter() getdb = database.get_db ## Define verison 1 API's here class UserCrudApi(): """This class is for user's CRUD operation with version 1 API's""" - - @router_v1.get('/users') - @version(1) + @router.get('/users') async def list_user(db: Session = Depends(getdb)): """This API is for list user. Args: None @@ -28,11 +24,10 @@ async def list_user(db: Session = Depends(getdb)): try: response = UserAuthService().get_user_service(db) return response - except Exception as e: + except Exception: return StandardResponse(False, status.HTTP_400_BAD_REQUEST, None, constant.ERROR_MSG) - - @router_v1.post('/create/user') - @version(1) + + @router.post('/create/user') async def create_user(body: schema.UserAuth, db: Session = Depends(getdb)): """This API is for create user. @@ -45,25 +40,5 @@ async def create_user(body: schema.UserAuth, body = body.model_dump() response = UserAuthService().create_user_service(db, body) return response - except Exception as e: - return StandardResponse(False, status.HTTP_400_BAD_REQUEST, None, constant.ERROR_MSG) - - -## Define version 2 API's here -router_v2 = APIRouter() - -class UserVersionApi(): - """This class provides version 2 API's for users""" - @router_v2.get("/list") - @version(2) - async def get_list(): - """ This API will list version 2 Api's - Args: None - Returns: - response: list - """ - try: - response = { "data": "User's list data" } - return StandardResponse(True, status.HTTP_200_OK, response, constant.STATUS_SUCCESS) - except Exception as e: + except Exception: return StandardResponse(False, status.HTTP_400_BAD_REQUEST, None, constant.ERROR_MSG) \ No newline at end of file diff --git a/apps/api/core/db_methods.py b/apps/api/core/db_methods.py index 966af01..c11388d 100644 --- a/apps/api/core/db_methods.py +++ b/apps/api/core/db_methods.py @@ -1,16 +1,18 @@ +"""This module contains databse methods.""" from fastapi import Depends -from config import database -from apps.constant import constant from sqlalchemy.orm import Session +from apps.constant import constant +from config import database + getdb = database.get_db class BaseMethods(): """This class provides basic DB methods""" - + def __init__(self, model): self.model = model - + def save(self, validate_data, db: Session = Depends(getdb)): """this function saves the object to the database for the given model Args: @@ -29,8 +31,9 @@ def save(self, validate_data, db: Session = Depends(getdb)): db.rollback() db.close() return constant.STATUS_FALSE - + def find_by_uuid(self, db: Session = Depends(getdb)): """This function is used to find users.""" - return db.query(self.model).filter(self.model.deleted_at == None).all() + return db.query(self.model).filter(self.model.deleted_at == constant.STATUS_NULL).all() + \ No newline at end of file diff --git a/apps/api/core/validation.py b/apps/api/core/validation.py index 8816fb0..e005097 100644 --- a/apps/api/core/validation.py +++ b/apps/api/core/validation.py @@ -1,32 +1,35 @@ +"""This module contains validations functionality.""" import re class ValidationMethods: - + """This class contains not null validation""" def not_null_validator(self, v, field): + """This function is used to check whether a field is not null""" if v == '': raise ValueError(f'{field} must be required') return v - + def password_validator(self, v): + """This function is used to validate the password.""" reg = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!#%*?&]{6,20}$" pattern = re.compile(reg) - - if not(match := re.search(pattern, v)): - """Length should be at least 6 - Length should be not be greater than 20 - Password should have at least one numeral - Password should have at least one uppercase letter - Password should have at least one lowercase letter - Password should have at least one of the symbols $@# - """ + + if not(re.search(pattern, v)): + # Length should be at least 6 + # Length should be not be greater than 20 + # Password should have at least one numeral + # Password should have at least one uppercase letter + # Password should have at least one lowercase letter + # Password should have at least one of the symbols $@# return False return True - + def email_validator(self, v): + """This function checks if the email address is valid or not.""" # regular expression for email validation reg = "([-!#-'*+/-9=?A-Z^-~]+(\.[-!#-'*+/-9=?A-Z^-~]+)*|\"([]!#-[^-~ \t]|(\\[\t -~]))+\")@([-!#-'*+/-9=?A-Z^-~]+(\.[-!#-'*+/-9=?A-Z^-~]+)*|\[[\t -Z^-~]*])" - + pattern = re.compile(reg) - if not(match := re.search(pattern, v)): - raise ValueError("Invalid Email format") - return v \ No newline at end of file + if not(re.search(pattern, v)): + raise ValueError("Invalid Email format") + return v diff --git a/apps/constant/constant.py b/apps/constant/constant.py index 5178a72..87fe12d 100644 --- a/apps/constant/constant.py +++ b/apps/constant/constant.py @@ -1,3 +1,5 @@ +"""This module contains constant messages.""" + STATUS_SUCCESS = "success" STATUS_FAIL = "fail" STATUS_ERROR = "error" @@ -16,4 +18,4 @@ ## error message ## ERROR_MSG = "Error while creating user!" -LIST_ERROR_MSG = "There is no list to retrieve!!" \ No newline at end of file +LIST_ERROR_MSG = "There is no list to retrieve!!" diff --git a/apps/utils/helper.py b/apps/utils/helper.py index 7099989..f9507ae 100644 --- a/apps/utils/helper.py +++ b/apps/utils/helper.py @@ -1,11 +1,12 @@ +"""This moduel is used to convert password into hash string.""" from passlib.context import CryptContext class PasswordUtils(): """This class is used to manage password management""" - + def __init__(self): self.pwd_context = CryptContext(schemes=['bcrypt'], deprecated="auto") - + def hash_password(self, password: str): """ This function is used to hash password @@ -15,4 +16,4 @@ def hash_password(self, password: str): Returns: Hash of the password """ - return self.pwd_context.hash(password) \ No newline at end of file + return self.pwd_context.hash(password) diff --git a/apps/utils/message.py b/apps/utils/message.py index 8aadd34..180d3b6 100644 --- a/apps/utils/message.py +++ b/apps/utils/message.py @@ -1,10 +1,12 @@ -# predefined messages. +"""This module contains predefined messages.""" class InfoMessage: + """This class contains information messages.""" userCreated = "User created successfully" retriveInfoSuccessfully = "Retrive user info successfully" class ErrorMessage: + """This class contains error messages.""" emailRequired = "Email is required" usernameRequired = "Username is required" usernameAlreadyExists = "Username already exists" @@ -13,4 +15,4 @@ class ErrorMessage: emailAlreadyExist = "E-Mail already exists." invalidPasswordFormat = "Password format is invalid" userNotFound = "User is not found" - userInvalid = "user is invalid" \ No newline at end of file + userInvalid = "user is invalid" diff --git a/apps/utils/standard_response.py b/apps/utils/standard_response.py index cce6187..af044f2 100644 --- a/apps/utils/standard_response.py +++ b/apps/utils/standard_response.py @@ -1,6 +1,9 @@ -from apps.constant import constant +"""This module contains standard response class.""" from fastapi.responses import JSONResponse +from apps.constant import constant + + class StandardResponse: """This class is universal to return standard API responses diff --git a/asgi.py b/asgi.py index 36b92fe..0b0d6b4 100644 --- a/asgi.py +++ b/asgi.py @@ -1,5 +1,8 @@ +"""This module contains entry point for an application.""" import os + import uvicorn + from apps.__init__ import app from config.env_config import load_dotenv @@ -9,4 +12,4 @@ uvicorn.run("asgi:app", host=os.environ.get('SERVER_HOST'), port=int(os.environ.get('SERVER_PORT')), - reload=True) \ No newline at end of file + reload=True) diff --git a/config/cors.py b/config/cors.py index dfc73aa..866126e 100644 --- a/config/cors.py +++ b/config/cors.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI +"""This module contains CORS headers.""" from starlette.middleware import Middleware from fastapi.middleware.cors import CORSMiddleware @@ -18,4 +18,4 @@ allow_methods=CORS_ALLOW_METHODS, allow_headers=CORS_ALLOW_HEADERS ) -] \ No newline at end of file +] diff --git a/config/database.py b/config/database.py index 411195b..2f2b6bc 100644 --- a/config/database.py +++ b/config/database.py @@ -1,10 +1,9 @@ """This config file will define Database Url and configurations""" -import os -from config import env_config from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from config import env_config # ##### DATABASE CONFIGURATION ############################ SQLALCHEMY_DATABASE_URL = f'mysql+pymysql://{env_config.DATABASE_USER}:{env_config.DATABASE_PASSWORD}@{env_config.DATABASE_HOST}:{env_config.DATABASE_PORT}/{env_config.DATABASE_NAME}' @@ -16,6 +15,7 @@ Base = declarative_base() def get_db(): + """This function returns the database object.""" db = SessionLocal() try: yield db diff --git a/config/env_config.py b/config/env_config.py index 37548ce..579adf0 100644 --- a/config/env_config.py +++ b/config/env_config.py @@ -1,6 +1,9 @@ +"""This module contains configuration information.""" import os -from dotenv import load_dotenv from os.path import join + +from dotenv import load_dotenv + from .project_path import BASE_DIR ##### ENV configuration ##### @@ -14,7 +17,3 @@ DATABASE_PASSWORD = os.environ.get("DATABASE_PASSWORD") DATABASE_HOST = os.environ.get("DATABASE_HOST") DATABASE_PORT = os.environ.get("DATABASE_PORT") - - - - diff --git a/config/project_path.py b/config/project_path.py index 9855c8f..6820465 100644 --- a/config/project_path.py +++ b/config/project_path.py @@ -1,3 +1,4 @@ +"""This module contains project path information.""" from os.path import abspath, basename, dirname, join diff --git a/requirements.txt b/requirements.txt index 1596377..24df1dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,23 +1,4 @@ -anyio==3.7.1 -click==8.1.7 -colorama==0.4.6 -exceptiongroup==1.1.3 -h11==0.14.0 -idna==3.4 -sniffio==1.3.0 -typing_extensions==4.8.0 -uvicorn==0.23.2 -annotated-types==0.6.0 -fastapi==0.104.1 -fastapi-versioning==0.10.0 -greenlet==3.0.1 -pip==23.2.1 -pydantic==2.5.2 -pydantic_core==2.14.5 -PyMySQL==1.1.0 -python-dotenv==1.0.0 -setuptools==68.0.0 -SQLAlchemy==1.4.50 -starlette==0.27.0 -typing_extensions==4.8.0 -wheel==0.38.4 +fastapi==0.111.0 +PyMySQL==1.1.1 +SQLAlchemy==2.0.30 +uvicorn==0.30.0 From 02b0070c37e5ba0240ea3c7b8efc31c9b03f0b46 Mon Sep 17 00:00:00 2001 From: vijay Date: Fri, 6 Dec 2024 12:36:29 +0530 Subject: [PATCH 6/6] feat: all fastapi middlewares --- apps/__init__.py | 16 +++++++++ apps/middleware/__init__.py | 1 + apps/middleware/authentication_middleware.py | 21 +++++++++++ apps/middleware/cors_middleware.py | 10 ++++++ apps/middleware/custom_middleware.py | 17 +++++++++ apps/middleware/error_handling_middleware.py | 13 +++++++ apps/middleware/logging_middleware.py | 15 ++++++++ apps/middleware/rate_limiting_middleware.py | 38 ++++++++++++++++++++ apps/middleware/request_size_middleware.py | 17 +++++++++ apps/middleware/response_time_middleware.py | 13 +++++++ apps/middleware/session_middleware.py | 22 ++++++++++++ 11 files changed, 183 insertions(+) create mode 100644 apps/middleware/__init__.py create mode 100644 apps/middleware/authentication_middleware.py create mode 100644 apps/middleware/cors_middleware.py create mode 100644 apps/middleware/custom_middleware.py create mode 100644 apps/middleware/error_handling_middleware.py create mode 100644 apps/middleware/logging_middleware.py create mode 100644 apps/middleware/rate_limiting_middleware.py create mode 100644 apps/middleware/request_size_middleware.py create mode 100644 apps/middleware/response_time_middleware.py create mode 100644 apps/middleware/session_middleware.py diff --git a/apps/__init__.py b/apps/__init__.py index 491093b..b003693 100644 --- a/apps/__init__.py +++ b/apps/__init__.py @@ -5,6 +5,13 @@ from apps.api.auth.view import router from apps.constant import constant from config import cors, database +from apps.middleware.custom_middleware import CustomMiddleware +from apps.middleware.request_size_middleware import LimitRequestSizeMiddleware +from apps.middleware.logging_middleware import LogRequestsMiddleware +from apps.middleware.authentication_middleware import AuthMiddleware +from apps.middleware.session_middleware import SessionMiddleware +from apps.middleware.rate_limiting_middleware import RateLimitMiddleware +from apps.middleware.error_handling_middleware import ErrorHandlingMiddleware # Bind with the database, whenever new models find it's create it. authbase.metadata.create_all(bind=database.engine) @@ -12,6 +19,15 @@ # Create app object and add routes app = FastAPI(title="Python FastAPI boilerplate", middleware=cors.middleware) +# add middlewares +app.add_middleware(CustomMiddleware) +app.add_middleware(LimitRequestSizeMiddleware, max_body_size=10 * 1024 * 1024) +app.add_middleware(LogRequestsMiddleware) +app.add_middleware(AuthMiddleware, secure_token="secure_token") +app.add_middleware(SessionMiddleware, secure_token="new_session_token") +app.add_middleware(RateLimitMiddleware, rate_limit=5, time_window=60) +app.add_middleware(ErrorHandlingMiddleware) + # define router for different version # router for API's app.include_router( diff --git a/apps/middleware/__init__.py b/apps/middleware/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/middleware/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/middleware/authentication_middleware.py b/apps/middleware/authentication_middleware.py new file mode 100644 index 0000000..7067ec5 --- /dev/null +++ b/apps/middleware/authentication_middleware.py @@ -0,0 +1,21 @@ +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response, JSONResponse + + + +class AuthMiddleware(BaseHTTPMiddleware): + def __init__(self, app, secure_token: str): + super().__init__(app) + self.secure_token = secure_token + + async def dispatch(self, request: Request, call_next): + token = request.headers.get("Authorization") + + # Validate the Authorization token + if not token or token != f"Bearer {self.secure_token}": + return JSONResponse(content={"error": "Unauthorized"}, status_code=401) + + # Proceed to the next middleware or endpoint + response = await call_next(request) + return response diff --git a/apps/middleware/cors_middleware.py b/apps/middleware/cors_middleware.py new file mode 100644 index 0000000..c194b0d --- /dev/null +++ b/apps/middleware/cors_middleware.py @@ -0,0 +1,10 @@ +from starlette.middleware.cors import CORSMiddleware + + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Replace "*" with specific origins for better security + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) diff --git a/apps/middleware/custom_middleware.py b/apps/middleware/custom_middleware.py new file mode 100644 index 0000000..e092b0b --- /dev/null +++ b/apps/middleware/custom_middleware.py @@ -0,0 +1,17 @@ +from fastapi import FastAPI, Request +from starlette.middleware.base import BaseHTTPMiddleware + +app = FastAPI() + +class CustomMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + # Logic before processing the request + print("Before Request") + + response = await call_next(request) + + # Logic after processing the request + print("After Request") + return response + + diff --git a/apps/middleware/error_handling_middleware.py b/apps/middleware/error_handling_middleware.py new file mode 100644 index 0000000..a3c7050 --- /dev/null +++ b/apps/middleware/error_handling_middleware.py @@ -0,0 +1,13 @@ +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse + +class ErrorHandlingMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + try: + # Proceed to the next middleware or endpoint + response = await call_next(request) + return response + except Exception as exc: + # Catch any exception and return a JSON response with status 500 + return JSONResponse(content={"error": str(exc)}, status_code=500) diff --git a/apps/middleware/logging_middleware.py b/apps/middleware/logging_middleware.py new file mode 100644 index 0000000..487f8c0 --- /dev/null +++ b/apps/middleware/logging_middleware.py @@ -0,0 +1,15 @@ +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +class LogRequestsMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + # Log incoming request details + print(f"Incoming request: {request.method} {request.url}") + + # Call the next middleware or endpoint + response = await call_next(request) + + # Log response details + print(f"Response status: {response.status_code}") + return response diff --git a/apps/middleware/rate_limiting_middleware.py b/apps/middleware/rate_limiting_middleware.py new file mode 100644 index 0000000..4885130 --- /dev/null +++ b/apps/middleware/rate_limiting_middleware.py @@ -0,0 +1,38 @@ +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response, JSONResponse +from time import time + + +class RateLimitMiddleware(BaseHTTPMiddleware): + def __init__(self, app, rate_limit: int, time_window: int = 60): + """ + :param app: The FastAPI app + :param rate_limit: Maximum number of requests allowed + :param time_window: Time window in seconds for rate limiting (default is 60 seconds) + """ + super().__init__(app) + self.rate_limit = rate_limit + self.time_window = time_window + self.request_count = 0 + self.window_start_time = time() + + async def dispatch(self, request: Request, call_next): + current_time = time() + + # Check if the time window has expired + if current_time - self.window_start_time > self.time_window: + # Reset the window + self.window_start_time = current_time + self.request_count = 0 + + # Enforce rate limiting + if self.request_count >= self.rate_limit: + return JSONResponse(content={"error": "Rate limit exceeded"}, status_code=429) + + # Increment request count + self.request_count += 1 + + # Process the request + response = await call_next(request) + return response diff --git a/apps/middleware/request_size_middleware.py b/apps/middleware/request_size_middleware.py new file mode 100644 index 0000000..9ead88e --- /dev/null +++ b/apps/middleware/request_size_middleware.py @@ -0,0 +1,17 @@ +from fastapi import status, HTTPException +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request + + +class LimitRequestSizeMiddleware(BaseHTTPMiddleware): + def __init__(self, app, max_body_size: int): + super().__init__(app) + self.max_body_size = max_body_size + + async def dispatch(self, request: Request, call_next): + if int(request.headers.get("content-length", 0)) > self.max_body_size: + raise HTTPException( + detail="Request entity too large", + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + ) + return await call_next(request) diff --git a/apps/middleware/response_time_middleware.py b/apps/middleware/response_time_middleware.py new file mode 100644 index 0000000..58ebe5f --- /dev/null +++ b/apps/middleware/response_time_middleware.py @@ -0,0 +1,13 @@ +import time +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + + +class TimingMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + start_time = time.time() + response: Response = await call_next(request) + process_time = time.time() - start_time + print(f"Request processing time: {process_time:.2f} seconds") + return response diff --git a/apps/middleware/session_middleware.py b/apps/middleware/session_middleware.py new file mode 100644 index 0000000..c5f990c --- /dev/null +++ b/apps/middleware/session_middleware.py @@ -0,0 +1,22 @@ +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response, JSONResponse + +class SessionMiddleware(BaseHTTPMiddleware): + def __init__(self, app, new_session_token: str): + super().__init__(app) + self.new_session_token = new_session_token + + async def dispatch(self, request: Request, call_next): + # Check for the session token in cookies + session_token = request.cookies.get("session_token") + if not session_token: + # Return a response indicating the session has expired + response = JSONResponse(content={"error": "Session expired"}, status_code=403) + # Set a new session token in the cookies + response.set_cookie(key="session_token", value=self.new_session_token) + return response + + # Proceed to the next middleware or endpoint + response = await call_next(request) + return response