From 74de1ecdf2a4510eb66608111d37e438b3f4f1f9 Mon Sep 17 00:00:00 2001 From: IndominusByte Date: Wed, 7 Oct 2020 19:06:41 +0800 Subject: [PATCH] add example on multiple files --- README.md | 2 +- examples/multiple_files/requirements.txt | 12 +++ examples/multiple_files/services/.env.example | 4 + examples/multiple_files/services/__init__.py | 0 examples/multiple_files/services/alembic.ini | 85 ++++++++++++++++ .../multiple_files/services/alembic/README | 1 + .../multiple_files/services/alembic/env.py | 93 ++++++++++++++++++ .../services/alembic/script.py.mako | 24 +++++ .../alembic/versions/4e36b7b730e5_init_db.py | 37 +++++++ examples/multiple_files/services/app.py | 16 +++ examples/multiple_files/services/config.py | 41 ++++++++ .../services/controller/UserController.py | 40 ++++++++ .../services/controller/__init__.py | 0 examples/multiple_files/services/database.py | 6 ++ .../services/models/UserModel.py | 10 ++ .../services/models/__init__.py | 0 .../multiple_files/services/routers/Users.py | 90 +++++++++++++++++ .../services/routers/__init__.py | 0 .../services/schemas/__init__.py | 0 .../services/schemas/users/RegisterSchema.py | 18 ++++ .../services/schemas/users/UserSchema.py | 18 ++++ .../services/schemas/users/__init__.py | 0 examples/multiple_files/services/sql.db | Bin 0 -> 20480 bytes .../multiple_files/services/tests/__init__.py | 0 24 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 examples/multiple_files/requirements.txt create mode 100644 examples/multiple_files/services/.env.example create mode 100644 examples/multiple_files/services/__init__.py create mode 100644 examples/multiple_files/services/alembic.ini create mode 100644 examples/multiple_files/services/alembic/README create mode 100644 examples/multiple_files/services/alembic/env.py create mode 100644 examples/multiple_files/services/alembic/script.py.mako create mode 100644 examples/multiple_files/services/alembic/versions/4e36b7b730e5_init_db.py create mode 100644 examples/multiple_files/services/app.py create mode 100644 examples/multiple_files/services/config.py create mode 100644 examples/multiple_files/services/controller/UserController.py create mode 100644 examples/multiple_files/services/controller/__init__.py create mode 100644 examples/multiple_files/services/database.py create mode 100644 examples/multiple_files/services/models/UserModel.py create mode 100644 examples/multiple_files/services/models/__init__.py create mode 100644 examples/multiple_files/services/routers/Users.py create mode 100644 examples/multiple_files/services/routers/__init__.py create mode 100644 examples/multiple_files/services/schemas/__init__.py create mode 100644 examples/multiple_files/services/schemas/users/RegisterSchema.py create mode 100644 examples/multiple_files/services/schemas/users/UserSchema.py create mode 100644 examples/multiple_files/services/schemas/users/__init__.py create mode 100644 examples/multiple_files/services/sql.db create mode 100644 examples/multiple_files/services/tests/__init__.py diff --git a/README.md b/README.md index 80cf132..023d8b4 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ There are: Optional: - [Use AuthJWT Without Dependency Injection](/examples/without_dependency.py) -- [On Mutiple Files]() +- [On Mutiple Files](/examples/multiple_files) ## License This project is licensed under the terms of the MIT license. diff --git a/examples/multiple_files/requirements.txt b/examples/multiple_files/requirements.txt new file mode 100644 index 0000000..1cc98c5 --- /dev/null +++ b/examples/multiple_files/requirements.txt @@ -0,0 +1,12 @@ +Cython +email-validator +pydantic --no-binary pydantic +SQLAlchemy +databases[sqlite] +bcrypt +uvicorn +python-dotenv +alembic +redis +fastapi +fastapi-jwt-auth diff --git a/examples/multiple_files/services/.env.example b/examples/multiple_files/services/.env.example new file mode 100644 index 0000000..797c8d4 --- /dev/null +++ b/examples/multiple_files/services/.env.example @@ -0,0 +1,4 @@ +DB_URL=sqlite:///./sql.db +REDIS_DB_HOST=localhost +AUTHJWT_BLACKLIST_ENABLED=true +AUTHJWT_SECRET_KEY=secretkey diff --git a/examples/multiple_files/services/__init__.py b/examples/multiple_files/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/multiple_files/services/alembic.ini b/examples/multiple_files/services/alembic.ini new file mode 100644 index 0000000..3decae2 --- /dev/null +++ b/examples/multiple_files/services/alembic.ini @@ -0,0 +1,85 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat alembic/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks=black +# black.type=console_scripts +# black.entrypoint=black +# black.options=-l 79 + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/examples/multiple_files/services/alembic/README b/examples/multiple_files/services/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/examples/multiple_files/services/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/examples/multiple_files/services/alembic/env.py b/examples/multiple_files/services/alembic/env.py new file mode 100644 index 0000000..f08538c --- /dev/null +++ b/examples/multiple_files/services/alembic/env.py @@ -0,0 +1,93 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +import os, sys +BASE_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)),"..") +sys.path.append(BASE_DIR) + +from config import settings +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config +config.set_main_option("sqlalchemy.url", settings.db_url) + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +from sqlalchemy import MetaData +from models import UserModel + +def combine_metadata(*args): + m = MetaData() + for metadata in args: + for t in metadata.tables.values(): + t.tometadata(m) + return m + + +target_metadata = combine_metadata(UserModel.metadata) +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/examples/multiple_files/services/alembic/script.py.mako b/examples/multiple_files/services/alembic/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/examples/multiple_files/services/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/examples/multiple_files/services/alembic/versions/4e36b7b730e5_init_db.py b/examples/multiple_files/services/alembic/versions/4e36b7b730e5_init_db.py new file mode 100644 index 0000000..ed3f782 --- /dev/null +++ b/examples/multiple_files/services/alembic/versions/4e36b7b730e5_init_db.py @@ -0,0 +1,37 @@ +"""init db + +Revision ID: 4e36b7b730e5 +Revises: +Create Date: 2020-10-07 17:23:10.739779 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '4e36b7b730e5' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=100), nullable=False), + sa.Column('email', sa.String(length=100), nullable=False), + sa.Column('password', sa.String(length=100), nullable=False), + sa.Column('role', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/examples/multiple_files/services/app.py b/examples/multiple_files/services/app.py new file mode 100644 index 0000000..a35120e --- /dev/null +++ b/examples/multiple_files/services/app.py @@ -0,0 +1,16 @@ +from fastapi import FastAPI +from database import database +from routers import Users + +app = FastAPI() + +@app.on_event("startup") +async def startup(): + await database.connect() + +@app.on_event("shutdown") +async def shutdown(): + await database.disconnect() + + +app.include_router(Users.router, prefix="/users", tags=['users']) diff --git a/examples/multiple_files/services/config.py b/examples/multiple_files/services/config.py new file mode 100644 index 0000000..d1c5aba --- /dev/null +++ b/examples/multiple_files/services/config.py @@ -0,0 +1,41 @@ +from os.path import abspath, dirname, join +from fastapi_jwt_auth import AuthJWT +from datetime import timedelta +from pydantic import BaseSettings +from typing import Literal +from redis import Redis + +ENV_FILE = join(dirname(abspath(__file__)),".env") + +class Settings(BaseSettings): + db_url: str + redis_db_host: str + authjwt_access_token_expires: timedelta = timedelta(minutes=15) + authjwt_refresh_token_expires: timedelta = timedelta(days=30) + # remember literal type only available for python 3.8 + authjwt_blacklist_enabled: Literal['true','false'] + authjwt_secret_key: str + + class Config: + env_file = ENV_FILE + env_file_encoding = "utf-8" + + +settings = Settings() + +conn_redis = Redis(host=settings.redis_db_host, port=6379, db=0,decode_responses=True) + +# You can load env from pydantic or environment variable +@AuthJWT.load_env +def get_setting(): + return settings + +@AuthJWT.token_in_blacklist_loader +def check_if_token_in_blacklist(decrypted_token): + jti = decrypted_token['jti'] + entry = conn_redis.get(jti) + return entry and entry == 'true' + + +ACCESS_EXPIRES = int(settings.authjwt_access_token_expires.total_seconds()) +REFRESH_EXPIRES = int(settings.authjwt_refresh_token_expires.total_seconds()) diff --git a/examples/multiple_files/services/controller/UserController.py b/examples/multiple_files/services/controller/UserController.py new file mode 100644 index 0000000..eec136a --- /dev/null +++ b/examples/multiple_files/services/controller/UserController.py @@ -0,0 +1,40 @@ +import bcrypt +from sqlalchemy import select +from fastapi import HTTPException +from models.UserModel import users +from database import database + +class UserLogic: + def check_user_password(password: str, hashed_pass: str) -> bool: + return bcrypt.checkpw(password.encode(), hashed_pass.encode()) + +class UserCrud: + async def create_user(**kwargs) -> int: + email_exists = await UserFetch.filter_by_email(kwargs['email']) + hashed_pass = bcrypt.hashpw(kwargs['password'].encode(), bcrypt.gensalt()) + kwargs.update({'password': hashed_pass.decode('utf-8')}) + if email_exists is None: + return await database.execute(query=users.insert(),values=kwargs) + raise HTTPException(status_code=400,detail="User already exists") + + async def update_user(user_id: int, **kwargs) -> None: + query = users.update().where(users.c.id == user_id).values(**kwargs) + await database.execute(query=query) + + async def delete_user(user_id: int) -> int: + user_exists = await UserFetch.filter_by_id(id=user_id) + if user_exists: + return await database.execute(query=users.delete().where(users.c.id == user_id)) + raise HTTPException(status_code=400,detail="User not found!") + +class UserFetch: + async def all_user() -> users: + return await database.fetch_all(query=select([users])) + + async def filter_by_email(email: str) -> users: + query = select([users]).where(users.c.email == email) + return await database.fetch_one(query=query) + + async def filter_by_id(id: int) -> users: + query = select([users]).where(users.c.id == id) + return await database.fetch_one(query=query) diff --git a/examples/multiple_files/services/controller/__init__.py b/examples/multiple_files/services/controller/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/multiple_files/services/database.py b/examples/multiple_files/services/database.py new file mode 100644 index 0000000..878bb03 --- /dev/null +++ b/examples/multiple_files/services/database.py @@ -0,0 +1,6 @@ +from sqlalchemy import MetaData +from databases import Database +from config import settings + +metadata = MetaData() +database = Database(settings.db_url) diff --git a/examples/multiple_files/services/models/UserModel.py b/examples/multiple_files/services/models/UserModel.py new file mode 100644 index 0000000..7b730e8 --- /dev/null +++ b/examples/multiple_files/services/models/UserModel.py @@ -0,0 +1,10 @@ +from database import metadata +from sqlalchemy import Table, Column, Integer, String + +users = Table("users", metadata, + Column('id', Integer, primary_key=True), + Column('username', String(100), nullable=False), + Column('email', String(100), unique=True, index=True, nullable=False), + Column('password', String(100), nullable=False), + Column('role', Integer, default=1) +) diff --git a/examples/multiple_files/services/models/__init__.py b/examples/multiple_files/services/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/multiple_files/services/routers/Users.py b/examples/multiple_files/services/routers/Users.py new file mode 100644 index 0000000..80af24b --- /dev/null +++ b/examples/multiple_files/services/routers/Users.py @@ -0,0 +1,90 @@ +from fastapi_jwt_auth import AuthJWT +from fastapi import APIRouter, Response, Depends +from controller.UserController import UserCrud, UserFetch, UserLogic +from schemas.users.RegisterSchema import RegisterSchema +from schemas.users.UserSchema import UserLogin, UserOut, UserUpdate +from config import conn_redis, ACCESS_EXPIRES, REFRESH_EXPIRES +from typing import List + +class JwtAuthToken: + def __init__(self): + self.jwt_auth = AuthJWT(None) + + def __call__(self): + return self.jwt_auth + + +router = APIRouter() +auth_token = JwtAuthToken() + +@router.post('/register', status_code=201) +async def register(user: RegisterSchema): + await UserCrud.create_user(**user.dict(exclude={'confirm_password'})) + return {"message":"email already register"} + +@router.post('/login') +async def login(user: UserLogin, res: Response, Authorize: AuthJWT = Depends(auth_token)): + user_exists = await UserFetch.filter_by_email(user.email) + if ( + user_exists and + UserLogic.check_user_password(password=user.password,hashed_pass=user_exists.password) + ): + access_token = Authorize.create_access_token(identity=user_exists.id,fresh=True) + refresh_token = Authorize.create_refresh_token(identity=user_exists.id) + + return { + "access_token": access_token, + "refresh_token": refresh_token, + "username": user_exists.username + } + + res.status_code = 422 + return {"message":"Invalid credential"} + +@router.post('/refresh-token') +async def refresh_token(Authorize: AuthJWT = Depends()): + Authorize.jwt_refresh_token_required() + + user_id = Authorize.get_jwt_identity() + new_token = Authorize.create_access_token(identity=user_id,fresh=False) + return {"access_token": new_token} + +@router.delete('/access-token-revoke') +async def access_token_revoke(Authorize: AuthJWT = Depends()): + Authorize.jwt_required() + + jti = Authorize.get_raw_jwt()['jti'] + conn_redis.setex(jti,ACCESS_EXPIRES,"true") + return {"message":"Access token revoked."} + +@router.delete('/refresh-token-revoke') +async def refresh_token_revoke(Authorize: AuthJWT = Depends()): + Authorize.jwt_refresh_token_required() + + jti = Authorize.get_raw_jwt()['jti'] + conn_redis.setex(jti,REFRESH_EXPIRES,"true") + return {"message":"Refresh token revoked."} + +@router.get('/me', response_model=UserOut) +async def get_my_user(Authorize: AuthJWT = Depends()): + Authorize.jwt_required() + + user_id = Authorize.get_jwt_identity() + return await UserFetch.filter_by_id(id=user_id) + +@router.get('/', response_model=List[UserOut]) +async def all_user(): + return await UserFetch.all_user() + +@router.put('/update') +async def update_user(user: UserUpdate, Authorize: AuthJWT = Depends()): + Authorize.fresh_jwt_required() + + user_id = Authorize.get_jwt_identity() + await UserCrud.update_user(user_id=user_id,**user.dict()) + return {"message": "Success update your account."} + +@router.delete('/{user_id}') +async def delete_user(user_id: int): + await UserCrud.delete_user(user_id=user_id) + return {"message": "Success delete user."} diff --git a/examples/multiple_files/services/routers/__init__.py b/examples/multiple_files/services/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/multiple_files/services/schemas/__init__.py b/examples/multiple_files/services/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/multiple_files/services/schemas/users/RegisterSchema.py b/examples/multiple_files/services/schemas/users/RegisterSchema.py new file mode 100644 index 0000000..360e748 --- /dev/null +++ b/examples/multiple_files/services/schemas/users/RegisterSchema.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel, EmailStr, constr, validator + +class RegisterSchema(BaseModel): + email: EmailStr + username: constr(min_length=3) + confirm_password: constr(min_length=6) + password: constr(min_length=6) + + @validator('password') + def validate_password(cls, v, values, **kwargs): + if 'confirm_password' in values and values['confirm_password'] != v: + raise ValueError("Password must match with confirmation.'") + return v + + class Config: + min_anystr_length = 1 + max_anystr_length = 100 + anystr_strip_whitespace = True diff --git a/examples/multiple_files/services/schemas/users/UserSchema.py b/examples/multiple_files/services/schemas/users/UserSchema.py new file mode 100644 index 0000000..44f6eef --- /dev/null +++ b/examples/multiple_files/services/schemas/users/UserSchema.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel, EmailStr, constr + +class UserSchema(BaseModel): + class Config: + min_anystr_length = 1 + max_anystr_length = 100 + anystr_strip_whitespace = True + +class UserLogin(UserSchema): + email: EmailStr + password: constr(min_length=6) + +class UserOut(UserSchema): + email: EmailStr + username: str + +class UserUpdate(UserSchema): + username: constr(min_length=3) diff --git a/examples/multiple_files/services/schemas/users/__init__.py b/examples/multiple_files/services/schemas/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/multiple_files/services/sql.db b/examples/multiple_files/services/sql.db new file mode 100644 index 0000000000000000000000000000000000000000..2df506e8c322d8a252e7fd3584e54e26f7f0ee33 GIT binary patch literal 20480 zcmeI&!EVzq7zc2>?K%>v3*wUNcbU|v2z3EoruahY%f2&UI+Wu_+(u*zXIWMY;DA_=2d6?eLxWBU$`+}?2HP|tnie5PVQ8n~qTQd5nyZhF;S z(=_?*)jS->Lba(&Z5hnBU-zBW7+s4dGOq8ubv$whKDncQ|1s%WK4cmuf78>*&1hoj){Vt?%~p%wYHBZG zERu0_7JQLPQ93PED~0`)8mXyk%VEKEk^Eg$=V@_M-GO`Jd$tszQiS00;*1JQT#F~y z`m$0thb_+3z09s+d5RCh#XME@pd9R=^h17@e~|y#8ZvX^CzBr}2tWV=5P$##AOHaf zKmY;|fB*#UiNGGO>^~Eo7vsb6VW%yg>vCT>|KF3Fi)}yv0uX=z1Rwwb2tWV=5P$## zmI6j~Ut2%_=g