From 656eb7bf34179304f282e6f212bb177504503d63 Mon Sep 17 00:00:00 2001 From: Andrew <26823162+andcarnivorous@users.noreply.github.com> Date: Tue, 12 Sep 2023 10:17:41 +0200 Subject: [PATCH 1/2] feat: Add fastapi-users support (#143) --- fastapi_template/cli.py | 80 +++++++++++++-- fastapi_template/template/cookiecutter.json | 11 ++- .../{{cookiecutter.project_name}}/.env | 3 + .../conditional_files.json | 7 ++ .../pyproject.toml | 7 ++ .../db_sa/models/users.py | 99 +++++++++++++++++++ .../{{cookiecutter.project_name}}/settings.py | 8 +- .../web/api/router.py | 7 ++ .../web/api/users/__init__.py | 4 + .../web/api/users/views.py | 52 ++++++++++ fastapi_template/tests/conftest.py | 2 + 11 files changed, 271 insertions(+), 9 deletions(-) create mode 100644 fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/db_sa/models/users.py create mode 100644 fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/users/__init__.py create mode 100644 fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/users/views.py diff --git a/fastapi_template/cli.py b/fastapi_template/cli.py index 58059f4..47de3ea 100644 --- a/fastapi_template/cli.py +++ b/fastapi_template/cli.py @@ -34,7 +34,7 @@ def db_menu_update_info(ctx: BuilderContext, menu: SingularMenuModel) -> Builder return ctx -def disable_orm(ctx: BuilderContext) -> MenuEntry: +def disable_orm(ctx: BuilderContext) -> Optional[MenuEntry]: if ctx.db == "none": ctx.orm = "none" return SKIP_ENTRY @@ -47,6 +47,12 @@ def do_not_ask_features_if_quite(ctx: BuilderContext) -> Optional[List[MenuEntry return None +def do_not_ask_features_if_no_users(ctx: BuilderContext) -> Optional[list[MenuEntry]]: + if not ctx.add_users: + return [SKIP_ENTRY] + return None + + def check_db(allowed_values: List[str]) -> Callable[[BuilderContext], bool]: def checker(ctx: BuilderContext) -> bool: return ctx.db not in allowed_values @@ -74,7 +80,8 @@ def checker(ctx: BuilderContext) -> bool: "Choose this option if you want to create a service with {name}.\n" "It's more suitable for {generic} web-services or services without databases.".format( name=colored("REST API", color="green"), - generic=colored("generic", color="cyan", attrs=["underline"]), + generic=colored("generic", color="cyan", + attrs=["underline"]), ) ), ), @@ -145,7 +152,8 @@ def checker(ctx: BuilderContext) -> bool: "{name} is the most popular database made by oracle.\n" "It's a good fit for {prod} application.".format( name=colored("MySQL", color="green"), - prod=colored("production-grade", color="cyan", attrs=["underline"]), + prod=colored("production-grade", color="cyan", + attrs=["underline"]), ) ), additional_info=Database( @@ -164,7 +172,8 @@ def checker(ctx: BuilderContext) -> bool: "{name} is second most popular open-source relational database.\n" "It's a good fit for {prod} application.".format( name=colored("PostgreSQL", color="green"), - prod=colored("production-grade", color="cyan", attrs=["underline"]), + prod=colored("production-grade", color="cyan", + attrs=["underline"]), ) ), additional_info=Database( @@ -239,7 +248,8 @@ def checker(ctx: BuilderContext) -> bool: "If you select this option, you will get only {what}.\n" "The rest {warn}.".format( what=colored("raw database", color="green"), - warn=colored("is up to you", color="red", attrs=["underline"]), + warn=colored("is up to you", color="red", + attrs=["underline"]), ) ), ), @@ -330,7 +340,25 @@ def checker(ctx: BuilderContext) -> bool: color="green", ), purpose1=colored("caching", color="cyan"), - purpose2=colored("storing temporary variables", color="cyan"), + purpose2=colored( + "storing temporary variables", color="cyan"), + ) + ), + ), + MenuEntry( + code="add_users", + cli_name="add_users", + user_view="Add fastapi-users support", + is_hidden=check_orm(["sqlalchemy"]), + description=( + "{name} is a user management extension.\n" + "Adds {purpose1} JWT or cookie endpoints and {purpose2} models CRUD's.".format( + name=colored( + "Fastapi-users", + color="cyan", + ), + purpose1=colored("authentication", color="cyan"), + purpose2=colored("user", color="cyan"), ) ), ), @@ -383,7 +411,8 @@ def checker(ctx: BuilderContext) -> bool: "This option will add {what} manifests to your project.\n" "But this option is {warn}, since if you want to use k8s, please create helm.".format( what=colored("kubernetes", color="green"), - warn=colored("deprecated", color="red", attrs=["underline"]), + warn=colored("deprecated", color="red", + attrs=["underline"]), ) ), ), @@ -534,6 +563,42 @@ def checker(ctx: BuilderContext) -> bool: ], ) +users_backend_menu = MultiselectMenuModel( + title="FastApi Users Backend", + code="users_menu", + description="Available backends for authentication with fastapi_users", + multiselect=True, + before_ask=do_not_ask_features_if_no_users, + entries=[ + MenuEntry( + code="cookie_auth", + cli_name="cookie auth", + user_view="Add authentication via cookie support", + description=( + "Adds {cookie} authentication support.".format( + cookie=colored( + "cookie", + color="green", + ) + ) + ), + ), + MenuEntry( + code="jwt_auth", + cli_name="jwt auth", + user_view="Add JWT auth support", + description=( + "Adds {name} authentication support.".format( + name=colored( + "JWT", + color="green", + ) + ) + ), + ), + ], +) + def handle_cli( menus: List[BaseMenuModel], @@ -575,6 +640,7 @@ def run_command(callback: Callable[[BuilderContext], None]) -> None: orm_menu, ci_menu, features_menu, + users_backend_menu, ] cmd = Command( diff --git a/fastapi_template/template/cookiecutter.json b/fastapi_template/template/cookiecutter.json index efa3634..6c553a5 100644 --- a/fastapi_template/template/cookiecutter.json +++ b/fastapi_template/template/cookiecutter.json @@ -65,6 +65,15 @@ "gunicorn": { "type": "bool" }, + "add_users": { + "type": "bool" + }, + "cookie_auth": { + "type": "bool" + }, + "jwt_auth": { + "type": "bool" + }, "_extensions": [ "cookiecutter.extensions.RandomStringExtension" ], @@ -72,4 +81,4 @@ "*.js", "*.css" ] -} \ No newline at end of file +} diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/.env b/fastapi_template/template/{{cookiecutter.project_name}}/.env index e7dc08b..7ade1f1 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/.env +++ b/fastapi_template/template/{{cookiecutter.project_name}}/.env @@ -4,3 +4,6 @@ {%- elif cookiecutter.db_info.name != 'none' %} {{cookiecutter.project_name | upper}}_DB_HOST=localhost {%- endif %} +{%- if cookiecutter.add_users == "True" %} +USERS_SECRET="" +{%- endif %} \ No newline at end of file diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/conditional_files.json b/fastapi_template/template/{{cookiecutter.project_name}}/conditional_files.json index 54b3875..7dd4f58 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/conditional_files.json +++ b/fastapi_template/template/{{cookiecutter.project_name}}/conditional_files.json @@ -120,6 +120,13 @@ "{{cookiecutter.project_name}}/tests/test_rabbit.py" ] }, + "Users model": { + "enabled": "{{cookiecutter.add_users}}", + "resources": [ + "{{cookiecutter.project_name}}/web/api/users", + "{{cookiecutter.project_name}}/db_sa/models/users.py" + ] + }, "Dummy model": { "enabled": "{{cookiecutter.add_dummy}}", "resources": [ diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/pyproject.toml b/fastapi_template/template/{{cookiecutter.project_name}}/pyproject.toml index 1f3e922..b7411ef 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/pyproject.toml +++ b/fastapi_template/template/{{cookiecutter.project_name}}/pyproject.toml @@ -17,6 +17,13 @@ uvicorn = { version = "^0.22.0", extras = ["standard"] } {%- if cookiecutter.gunicorn == "True" %} gunicorn = "^21.2.0" {%- endif %} +{%- if cookiecutter.add_users == "True" %} +{%- if cookiecutter.orm == "sqlalchemy" %} +fastapi-users = "^12.1.2" +httpx-oauth = "^0.10.2" +fastapi-users-db-sqlalchemy = "^6.0.1" +{%- endif %} +{%- endif %} {%- if cookiecutter.pydanticv1 == "True" %} pydantic = { version = "^1", extras=["dotenv"] } {%- else %} diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/db_sa/models/users.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/db_sa/models/users.py new file mode 100644 index 0000000..783b586 --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/db_sa/models/users.py @@ -0,0 +1,99 @@ +# type: ignore +import uuid + +from fastapi import Depends +from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, schemas +from fastapi_users.authentication import ( + AuthenticationBackend, + BearerTransport, + + CookieTransport, + JWTStrategy, +) +from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyUserDatabase +from sqlalchemy.ext.asyncio import AsyncSession + +from {{cookiecutter.project_name}}.db.base import Base +from {{cookiecutter.project_name}}.db.dependencies import get_db_session +from {{cookiecutter.project_name}}.settings import settings + + +class User(SQLAlchemyBaseUserTableUUID, Base): + """Represents a user entity.""" + + +class UserRead(schemas.BaseUser[uuid.UUID]): + """Represents a read command for a user.""" + + +class UserCreate(schemas.BaseUserCreate): + """Represents a create command for a user.""" + + +class UserUpdate(schemas.BaseUserUpdate): + """Represents an update command for a user.""" + + +class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): + """Manages a user session and its tokens.""" + reset_password_token_secret = settings.users_secret + verification_token_secret = settings.users_secret + + +async def get_user_db(session: AsyncSession = Depends(get_db_session)) -> SQLAlchemyUserDatabase: + """ + Yield a SQLAlchemyUserDatabase instance. + + :param session: asynchronous SQLAlchemy session. + :yields: instance of SQLAlchemyUserDatabase. + """ + yield SQLAlchemyUserDatabase(session, User) + + +async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)) -> UserManager: + """ + Yield a UserManager instance. + + :param user_db: SQLAlchemy user db instance + :yields: an instance of UserManager. + """ + yield UserManager(user_db) + + +def get_jwt_strategy() -> JWTStrategy: + """ + Return a JWTStrategy in order to instantiate it dynamically. + + :returns: instance of JWTStrategy with provided settings. + """ + return JWTStrategy(secret=settings.users_secret, lifetime_seconds=None) + + +{%- if cookiecutter.jwt_auth == "True" %} +bearer_transport = BearerTransport(tokenUrl="auth/jwt/login") +auth_jwt = AuthenticationBackend( + name="jwt", + transport=bearer_transport, + get_strategy=get_jwt_strategy, +) +{%- endif %} + +{%- if cookiecutter.cookie_auth == "True" %} +cookie_transport = CookieTransport() +auth_cookie = AuthenticationBackend( + name="cookie", transport=cookie_transport, get_strategy=get_jwt_strategy +) +{%- endif %} + +backends = [ + {%- if cookiecutter.cookie_auth == "True" %} + auth_cookie, + {%- endif %} + {%- if cookiecutter.jwt_auth == "True" %} + auth_jwt, + {%- endif %} +] + +api_users = FastAPIUsers[User, uuid.UUID](get_user_manager, backends) + +current_active_user = api_users.current_user(active=True) diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/settings.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/settings.py index 534045c..7cbec7b 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/settings.py +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/settings.py @@ -1,3 +1,4 @@ +import os import enum from pathlib import Path from tempfile import gettempdir @@ -43,9 +44,14 @@ class Settings(BaseSettings): # Current environment environment: str = "dev" - + log_level: LogLevel = LogLevel.INFO + {%- if cookiecutter.add_users == "True" %} + {%- if cookiecutter.orm == "sqlalchemy" %} + users_secret: str = os.getenv("USERS_SECRET", "") + {%- endif %} + {%- endif %} {% if cookiecutter.db_info.name != "none" -%} # Variables for the database diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/router.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/router.py index 0ccf526..dd02c1a 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/router.py +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/router.py @@ -1,5 +1,9 @@ from fastapi.routing import APIRouter +{%- if cookiecutter.add_users == 'True' %} +from {{cookiecutter.project_name}}.web.api import users +from {{cookiecutter.project_name}}.db.models.users import api_users +{%- endif %} {%- if cookiecutter.enable_routers == "True" %} {%- if cookiecutter.api_type == 'rest' %} from {{cookiecutter.project_name}}.web.api import echo @@ -30,6 +34,9 @@ api_router = APIRouter() api_router.include_router(monitoring.router) +{%- if cookiecutter.add_users == 'True' %} +api_router.include_router(users.router) +{%- endif %} {%- if cookiecutter.self_hosted_swagger == "True" %} api_router.include_router(docs.router) {%- endif %} diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/users/__init__.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/users/__init__.py new file mode 100644 index 0000000..5c91b67 --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/users/__init__.py @@ -0,0 +1,4 @@ +"""API for checking project status.""" +from {{cookiecutter.project_name}}.web.api.users.views import router + +__all__ = ["router"] diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/users/views.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/users/views.py new file mode 100644 index 0000000..6a11b7c --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/users/views.py @@ -0,0 +1,52 @@ +from fastapi import APIRouter + +from {{cookiecutter.project_name}}.db.models.users import ( + UserCreate, # type: ignore + UserRead, # type: ignore + UserUpdate, # type: ignore + api_users, # type: ignore + auth_jwt, # type: ignore + auth_cookie, # type: ignore +) + + +router = APIRouter() + +router.include_router( + api_users.get_register_router(UserRead, UserCreate), + prefix="/auth", + tags=["auth"], +) + +router.include_router( + api_users.get_reset_password_router(), + prefix="/auth", + tags=["auth"], +) + +router.include_router( + api_users.get_verify_router(UserRead), + prefix="/auth", + tags=["auth"], +) + +router.include_router( + api_users.get_users_router(UserRead, UserUpdate), + prefix="/users", + tags=["users"], +) +{%- if cookiecutter.jwt_auth == "True" %} +router.include_router( + api_users.get_auth_router(auth_jwt), + prefix="/auth/jwt", + tags=["auth"] +) +{%- endif %} + +{%- if cookiecutter.cookie_auth == "True" %} +router.include_router( + api_users.get_auth_router(auth_cookie), + prefix="/auth/cookie", + tags=["auth"] +) +{%- endif %} diff --git a/fastapi_template/tests/conftest.py b/fastapi_template/tests/conftest.py index 02b54ca..9eae3b7 100644 --- a/fastapi_template/tests/conftest.py +++ b/fastapi_template/tests/conftest.py @@ -9,6 +9,7 @@ from fastapi_template.input_model import BuilderContext, Database from fastapi_template.tests.utils import run_docker_compose_command, model_dump_compat + @pytest.fixture def project_name(worker_id: str) -> str: """ @@ -58,6 +59,7 @@ def default_context(project_name: str) -> None: db_info=model_dump_compat(Database(name="none")), enable_redis=False, enable_taskiq=False, + add_users=False, enable_migrations=False, enable_kube=False, enable_routers=True, From 6793473fa47b8ab5335300a9c67fc508586f3dcb Mon Sep 17 00:00:00 2001 From: Pavel Kirilin Date: Tue, 12 Sep 2023 12:20:40 +0400 Subject: [PATCH 2/2] Version bumped to 5.1.0 Signed-off-by: Pavel Kirilin --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5373380..73ffd8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "fastapi_template" -version = "5.0.3" +version = "5.1.0" description = "Feature-rich robust FastAPI template" authors = ["Pavel Kirilin "] packages = [{ include = "fastapi_template" }]