Skip to content

Commit 6eec110

Browse files
committed
Merge branch 'release/5.1.0'
2 parents ddf31d4 + 6793473 commit 6eec110

File tree

12 files changed

+272
-10
lines changed

12 files changed

+272
-10
lines changed

fastapi_template/cli.py

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def db_menu_update_info(ctx: BuilderContext, menu: SingularMenuModel) -> Builder
3434
return ctx
3535

3636

37-
def disable_orm(ctx: BuilderContext) -> MenuEntry:
37+
def disable_orm(ctx: BuilderContext) -> Optional[MenuEntry]:
3838
if ctx.db == "none":
3939
ctx.orm = "none"
4040
return SKIP_ENTRY
@@ -47,6 +47,12 @@ def do_not_ask_features_if_quite(ctx: BuilderContext) -> Optional[List[MenuEntry
4747
return None
4848

4949

50+
def do_not_ask_features_if_no_users(ctx: BuilderContext) -> Optional[list[MenuEntry]]:
51+
if not ctx.add_users:
52+
return [SKIP_ENTRY]
53+
return None
54+
55+
5056
def check_db(allowed_values: List[str]) -> Callable[[BuilderContext], bool]:
5157
def checker(ctx: BuilderContext) -> bool:
5258
return ctx.db not in allowed_values
@@ -74,7 +80,8 @@ def checker(ctx: BuilderContext) -> bool:
7480
"Choose this option if you want to create a service with {name}.\n"
7581
"It's more suitable for {generic} web-services or services without databases.".format(
7682
name=colored("REST API", color="green"),
77-
generic=colored("generic", color="cyan", attrs=["underline"]),
83+
generic=colored("generic", color="cyan",
84+
attrs=["underline"]),
7885
)
7986
),
8087
),
@@ -145,7 +152,8 @@ def checker(ctx: BuilderContext) -> bool:
145152
"{name} is the most popular database made by oracle.\n"
146153
"It's a good fit for {prod} application.".format(
147154
name=colored("MySQL", color="green"),
148-
prod=colored("production-grade", color="cyan", attrs=["underline"]),
155+
prod=colored("production-grade", color="cyan",
156+
attrs=["underline"]),
149157
)
150158
),
151159
additional_info=Database(
@@ -164,7 +172,8 @@ def checker(ctx: BuilderContext) -> bool:
164172
"{name} is second most popular open-source relational database.\n"
165173
"It's a good fit for {prod} application.".format(
166174
name=colored("PostgreSQL", color="green"),
167-
prod=colored("production-grade", color="cyan", attrs=["underline"]),
175+
prod=colored("production-grade", color="cyan",
176+
attrs=["underline"]),
168177
)
169178
),
170179
additional_info=Database(
@@ -239,7 +248,8 @@ def checker(ctx: BuilderContext) -> bool:
239248
"If you select this option, you will get only {what}.\n"
240249
"The rest {warn}.".format(
241250
what=colored("raw database", color="green"),
242-
warn=colored("is up to you", color="red", attrs=["underline"]),
251+
warn=colored("is up to you", color="red",
252+
attrs=["underline"]),
243253
)
244254
),
245255
),
@@ -330,7 +340,25 @@ def checker(ctx: BuilderContext) -> bool:
330340
color="green",
331341
),
332342
purpose1=colored("caching", color="cyan"),
333-
purpose2=colored("storing temporary variables", color="cyan"),
343+
purpose2=colored(
344+
"storing temporary variables", color="cyan"),
345+
)
346+
),
347+
),
348+
MenuEntry(
349+
code="add_users",
350+
cli_name="add_users",
351+
user_view="Add fastapi-users support",
352+
is_hidden=check_orm(["sqlalchemy"]),
353+
description=(
354+
"{name} is a user management extension.\n"
355+
"Adds {purpose1} JWT or cookie endpoints and {purpose2} models CRUD's.".format(
356+
name=colored(
357+
"Fastapi-users",
358+
color="cyan",
359+
),
360+
purpose1=colored("authentication", color="cyan"),
361+
purpose2=colored("user", color="cyan"),
334362
)
335363
),
336364
),
@@ -383,7 +411,8 @@ def checker(ctx: BuilderContext) -> bool:
383411
"This option will add {what} manifests to your project.\n"
384412
"But this option is {warn}, since if you want to use k8s, please create helm.".format(
385413
what=colored("kubernetes", color="green"),
386-
warn=colored("deprecated", color="red", attrs=["underline"]),
414+
warn=colored("deprecated", color="red",
415+
attrs=["underline"]),
387416
)
388417
),
389418
),
@@ -534,6 +563,42 @@ def checker(ctx: BuilderContext) -> bool:
534563
],
535564
)
536565

566+
users_backend_menu = MultiselectMenuModel(
567+
title="FastApi Users Backend",
568+
code="users_menu",
569+
description="Available backends for authentication with fastapi_users",
570+
multiselect=True,
571+
before_ask=do_not_ask_features_if_no_users,
572+
entries=[
573+
MenuEntry(
574+
code="cookie_auth",
575+
cli_name="cookie auth",
576+
user_view="Add authentication via cookie support",
577+
description=(
578+
"Adds {cookie} authentication support.".format(
579+
cookie=colored(
580+
"cookie",
581+
color="green",
582+
)
583+
)
584+
),
585+
),
586+
MenuEntry(
587+
code="jwt_auth",
588+
cli_name="jwt auth",
589+
user_view="Add JWT auth support",
590+
description=(
591+
"Adds {name} authentication support.".format(
592+
name=colored(
593+
"JWT",
594+
color="green",
595+
)
596+
)
597+
),
598+
),
599+
],
600+
)
601+
537602

538603
def handle_cli(
539604
menus: List[BaseMenuModel],
@@ -575,6 +640,7 @@ def run_command(callback: Callable[[BuilderContext], None]) -> None:
575640
orm_menu,
576641
ci_menu,
577642
features_menu,
643+
users_backend_menu,
578644
]
579645

580646
cmd = Command(

fastapi_template/template/cookiecutter.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,20 @@
6565
"gunicorn": {
6666
"type": "bool"
6767
},
68+
"add_users": {
69+
"type": "bool"
70+
},
71+
"cookie_auth": {
72+
"type": "bool"
73+
},
74+
"jwt_auth": {
75+
"type": "bool"
76+
},
6877
"_extensions": [
6978
"cookiecutter.extensions.RandomStringExtension"
7079
],
7180
"_copy_without_render": [
7281
"*.js",
7382
"*.css"
7483
]
75-
}
84+
}

fastapi_template/template/{{cookiecutter.project_name}}/.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@
44
{%- elif cookiecutter.db_info.name != 'none' %}
55
{{cookiecutter.project_name | upper}}_DB_HOST=localhost
66
{%- endif %}
7+
{%- if cookiecutter.add_users == "True" %}
8+
USERS_SECRET=""
9+
{%- endif %}

fastapi_template/template/{{cookiecutter.project_name}}/conditional_files.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,13 @@
120120
"{{cookiecutter.project_name}}/tests/test_rabbit.py"
121121
]
122122
},
123+
"Users model": {
124+
"enabled": "{{cookiecutter.add_users}}",
125+
"resources": [
126+
"{{cookiecutter.project_name}}/web/api/users",
127+
"{{cookiecutter.project_name}}/db_sa/models/users.py"
128+
]
129+
},
123130
"Dummy model": {
124131
"enabled": "{{cookiecutter.add_dummy}}",
125132
"resources": [

fastapi_template/template/{{cookiecutter.project_name}}/pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ uvicorn = { version = "^0.22.0", extras = ["standard"] }
1717
{%- if cookiecutter.gunicorn == "True" %}
1818
gunicorn = "^21.2.0"
1919
{%- endif %}
20+
{%- if cookiecutter.add_users == "True" %}
21+
{%- if cookiecutter.orm == "sqlalchemy" %}
22+
fastapi-users = "^12.1.2"
23+
httpx-oauth = "^0.10.2"
24+
fastapi-users-db-sqlalchemy = "^6.0.1"
25+
{%- endif %}
26+
{%- endif %}
2027
{%- if cookiecutter.pydanticv1 == "True" %}
2128
pydantic = { version = "^1", extras=["dotenv"] }
2229
{%- else %}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# type: ignore
2+
import uuid
3+
4+
from fastapi import Depends
5+
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, schemas
6+
from fastapi_users.authentication import (
7+
AuthenticationBackend,
8+
BearerTransport,
9+
10+
CookieTransport,
11+
JWTStrategy,
12+
)
13+
from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyUserDatabase
14+
from sqlalchemy.ext.asyncio import AsyncSession
15+
16+
from {{cookiecutter.project_name}}.db.base import Base
17+
from {{cookiecutter.project_name}}.db.dependencies import get_db_session
18+
from {{cookiecutter.project_name}}.settings import settings
19+
20+
21+
class User(SQLAlchemyBaseUserTableUUID, Base):
22+
"""Represents a user entity."""
23+
24+
25+
class UserRead(schemas.BaseUser[uuid.UUID]):
26+
"""Represents a read command for a user."""
27+
28+
29+
class UserCreate(schemas.BaseUserCreate):
30+
"""Represents a create command for a user."""
31+
32+
33+
class UserUpdate(schemas.BaseUserUpdate):
34+
"""Represents an update command for a user."""
35+
36+
37+
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
38+
"""Manages a user session and its tokens."""
39+
reset_password_token_secret = settings.users_secret
40+
verification_token_secret = settings.users_secret
41+
42+
43+
async def get_user_db(session: AsyncSession = Depends(get_db_session)) -> SQLAlchemyUserDatabase:
44+
"""
45+
Yield a SQLAlchemyUserDatabase instance.
46+
47+
:param session: asynchronous SQLAlchemy session.
48+
:yields: instance of SQLAlchemyUserDatabase.
49+
"""
50+
yield SQLAlchemyUserDatabase(session, User)
51+
52+
53+
async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)) -> UserManager:
54+
"""
55+
Yield a UserManager instance.
56+
57+
:param user_db: SQLAlchemy user db instance
58+
:yields: an instance of UserManager.
59+
"""
60+
yield UserManager(user_db)
61+
62+
63+
def get_jwt_strategy() -> JWTStrategy:
64+
"""
65+
Return a JWTStrategy in order to instantiate it dynamically.
66+
67+
:returns: instance of JWTStrategy with provided settings.
68+
"""
69+
return JWTStrategy(secret=settings.users_secret, lifetime_seconds=None)
70+
71+
72+
{%- if cookiecutter.jwt_auth == "True" %}
73+
bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
74+
auth_jwt = AuthenticationBackend(
75+
name="jwt",
76+
transport=bearer_transport,
77+
get_strategy=get_jwt_strategy,
78+
)
79+
{%- endif %}
80+
81+
{%- if cookiecutter.cookie_auth == "True" %}
82+
cookie_transport = CookieTransport()
83+
auth_cookie = AuthenticationBackend(
84+
name="cookie", transport=cookie_transport, get_strategy=get_jwt_strategy
85+
)
86+
{%- endif %}
87+
88+
backends = [
89+
{%- if cookiecutter.cookie_auth == "True" %}
90+
auth_cookie,
91+
{%- endif %}
92+
{%- if cookiecutter.jwt_auth == "True" %}
93+
auth_jwt,
94+
{%- endif %}
95+
]
96+
97+
api_users = FastAPIUsers[User, uuid.UUID](get_user_manager, backends)
98+
99+
current_active_user = api_users.current_user(active=True)

fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/settings.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
import enum
23
from pathlib import Path
34
from tempfile import gettempdir
@@ -43,9 +44,14 @@ class Settings(BaseSettings):
4344

4445
# Current environment
4546
environment: str = "dev"
46-
47+
4748
log_level: LogLevel = LogLevel.INFO
4849

50+
{%- if cookiecutter.add_users == "True" %}
51+
{%- if cookiecutter.orm == "sqlalchemy" %}
52+
users_secret: str = os.getenv("USERS_SECRET", "")
53+
{%- endif %}
54+
{%- endif %}
4955
{% if cookiecutter.db_info.name != "none" -%}
5056

5157
# Variables for the database

fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/router.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
from fastapi.routing import APIRouter
22

3+
{%- if cookiecutter.add_users == 'True' %}
4+
from {{cookiecutter.project_name}}.web.api import users
5+
from {{cookiecutter.project_name}}.db.models.users import api_users
6+
{%- endif %}
37
{%- if cookiecutter.enable_routers == "True" %}
48
{%- if cookiecutter.api_type == 'rest' %}
59
from {{cookiecutter.project_name}}.web.api import echo
@@ -30,6 +34,9 @@
3034

3135
api_router = APIRouter()
3236
api_router.include_router(monitoring.router)
37+
{%- if cookiecutter.add_users == 'True' %}
38+
api_router.include_router(users.router)
39+
{%- endif %}
3340
{%- if cookiecutter.self_hosted_swagger == "True" %}
3441
api_router.include_router(docs.router)
3542
{%- endif %}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""API for checking project status."""
2+
from {{cookiecutter.project_name}}.web.api.users.views import router
3+
4+
__all__ = ["router"]

0 commit comments

Comments
 (0)