Skip to content

Commit d0eb0f8

Browse files
committed
Initial user model, schemas, crud and routes
1 parent 4aebb69 commit d0eb0f8

File tree

22 files changed

+1118
-113
lines changed

22 files changed

+1118
-113
lines changed

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,37 @@
11
# FastAPI-boilerplate
22
A boilerplate for Fastapi
3+
4+
## Running PostgreSQL with docker:
5+
After installing docker, run:
6+
```sh
7+
docker pull postgres
8+
```
9+
10+
Then pick the port, name, user and password and replace them:
11+
```sh
12+
docker run -d \
13+
-p {PORT}:{PORT} \
14+
--name {NAME} \
15+
-e POSTGRES_PASSWORD={PASSWORD} \
16+
-e POSTGRES_USER={USER} \
17+
postgres
18+
```
19+
20+
Such as:
21+
```sh
22+
docker run -d \
23+
-p 5432:5432 \
24+
--name postgres \
25+
-e POSTGRES_PASSWORD=1234 \
26+
-e POSTGRES_USER=postgres \
27+
postgres
28+
```
29+
30+
And create the variables in a .env file in the src folder:
31+
```
32+
POSTGRES_USER="postgres"
33+
POSTGRES_PASSWORD=1234
34+
POSTGRES_SERVER="localhost"
35+
POSTGRES_PORT=5432
36+
POSTGRES_DB="postgres"
37+
```

src/alembic.ini

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# A generic, single database configuration.
2+
3+
[alembic]
4+
# path to migration scripts
5+
script_location = migrations
6+
7+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
8+
# Uncomment the line below if you want the files to be prepended with date and time
9+
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
10+
# for all available tokens
11+
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
12+
13+
# sys.path path, will be prepended to sys.path if present.
14+
# defaults to the current working directory.
15+
prepend_sys_path = .
16+
17+
# timezone to use when rendering the date within the migration file
18+
# as well as the filename.
19+
# If specified, requires the python-dateutil library that can be
20+
# installed by adding `alembic[tz]` to the pip requirements
21+
# string value is passed to dateutil.tz.gettz()
22+
# leave blank for localtime
23+
# timezone =
24+
25+
# max length of characters to apply to the
26+
# "slug" field
27+
# truncate_slug_length = 40
28+
29+
# set to 'true' to run the environment during
30+
# the 'revision' command, regardless of autogenerate
31+
# revision_environment = false
32+
33+
# set to 'true' to allow .pyc and .pyo files without
34+
# a source .py file to be detected as revisions in the
35+
# versions/ directory
36+
# sourceless = false
37+
38+
# version location specification; This defaults
39+
# to migrations/versions. When using multiple version
40+
# directories, initial revisions must be specified with --version-path.
41+
# The path separator used here should be the separator specified by "version_path_separator" below.
42+
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
43+
44+
# version path separator; As mentioned above, this is the character used to split
45+
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
46+
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
47+
# Valid values for version_path_separator are:
48+
#
49+
# version_path_separator = :
50+
# version_path_separator = ;
51+
# version_path_separator = space
52+
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
53+
54+
# set to 'true' to search source files recursively
55+
# in each "version_locations" directory
56+
# new in Alembic version 1.10
57+
# recursive_version_locations = false
58+
59+
# the output encoding used when revision files
60+
# are written from script.py.mako
61+
# output_encoding = utf-8
62+
63+
sqlalchemy.url = driver://user:pass@localhost/dbname
64+
65+
66+
[post_write_hooks]
67+
# post_write_hooks defines scripts or Python functions that are run
68+
# on newly generated revision scripts. See the documentation for further
69+
# detail and examples
70+
71+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
72+
# hooks = black
73+
# black.type = console_scripts
74+
# black.entrypoint = black
75+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
76+
77+
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
78+
# hooks = ruff
79+
# ruff.type = exec
80+
# ruff.executable = %(here)s/.venv/bin/ruff
81+
# ruff.options = --fix REVISION_SCRIPT_FILENAME
82+
83+
# Logging configuration
84+
[loggers]
85+
keys = root,sqlalchemy,alembic
86+
87+
[handlers]
88+
keys = console
89+
90+
[formatters]
91+
keys = generic
92+
93+
[logger_root]
94+
level = WARN
95+
handlers = console
96+
qualname =
97+
98+
[logger_sqlalchemy]
99+
level = WARN
100+
handlers =
101+
qualname = sqlalchemy.engine
102+
103+
[logger_alembic]
104+
level = INFO
105+
handlers =
106+
qualname = alembic
107+
108+
[handler_console]
109+
class = StreamHandler
110+
args = (sys.stderr,)
111+
level = NOTSET
112+
formatter = generic
113+
114+
[formatter_generic]
115+
format = %(levelname)-5.5s [%(name)s] %(message)s
116+
datefmt = %H:%M:%S

src/app/api/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from fastapi import APIRouter
2+
3+
from app.api.v1 import router as v1_router
4+
5+
router = APIRouter(prefix="/api")
6+
router.include_router(v1_router)

src/app/api/dependencies.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from typing import Annotated
2+
3+
from app.core.security import SECRET_KEY, ALGORITHM, oauth2_scheme
4+
5+
from sqlalchemy.ext.asyncio import AsyncSession
6+
from jose import JWTError, jwt
7+
from fastapi import Depends, HTTPException, status
8+
9+
from app.core.database import async_get_db
10+
from app.crud.crud_users import get_user_by_email, get_user_by_username
11+
from app.core.models import TokenData
12+
13+
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)], db: Annotated[AsyncSession, Depends(async_get_db)]):
14+
credentials_exception = HTTPException(
15+
status_code=status.HTTP_401_UNAUTHORIZED,
16+
detail="Could not validate credentials",
17+
headers={"WWW-Authenticate": "Bearer"},
18+
)
19+
try:
20+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
21+
username_or_email: str = payload.get("sub")
22+
if username_or_email is None:
23+
raise credentials_exception
24+
token_data = TokenData(username_or_email=username_or_email)
25+
26+
except JWTError:
27+
raise credentials_exception
28+
29+
if "@" in username_or_email:
30+
user = await get_user_by_email(db=db, email=token_data.username_or_email)
31+
else:
32+
user = await get_user_by_username(db=db, username=token_data.username_or_email)
33+
34+
if user is None:
35+
raise credentials_exception
36+
37+
if user.is_deleted:
38+
raise HTTPException(status_code=400, detai="User deleted")
39+
40+
return user

src/app/api/v1/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from fastapi import APIRouter
2+
3+
from app.api.v1.login import router as login_router
4+
from app.api.v1.users import router as users_router
5+
6+
router = APIRouter(prefix="/v1")
7+
router.include_router(login_router)
8+
router.include_router(users_router)

src/app/api/v1/login.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from typing import Annotated
2+
from datetime import timedelta
3+
4+
from fastapi import Depends, HTTPException, status
5+
from fastapi.security import OAuth2PasswordRequestForm
6+
from sqlalchemy.ext.asyncio import AsyncSession
7+
import fastapi
8+
from app.core.database import async_get_db
9+
from app.core.models import Token
10+
from app.core.security import create_access_token, authenticate_user_by_username, authenticate_user_by_email, ACCESS_TOKEN_EXPIRE_MINUTES
11+
12+
router = fastapi.APIRouter(tags=["login"])
13+
14+
@router.post("/login", response_model=Token)
15+
async def login_for_access_token(
16+
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
17+
db: AsyncSession = Depends(async_get_db)
18+
):
19+
if "@" in form_data.username:
20+
user = await authenticate_user_by_email(form_data.username, form_data.password, db=db)
21+
else:
22+
user = await authenticate_user_by_username(form_data.username, form_data.password, db=db)
23+
24+
if not user:
25+
raise HTTPException(
26+
status_code=status.HTTP_401_UNAUTHORIZED,
27+
detail="Incorrect email, username or password",
28+
headers={"WWW-Authenticate": "Bearer"},
29+
)
30+
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
31+
access_token = await create_access_token(
32+
data={"sub": user.username}, expires_delta=access_token_expires
33+
)
34+
return {"access_token": access_token, "token_type": "bearer"}

src/app/api/v1/users.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from typing import List, Annotated
2+
3+
from fastapi import Depends, HTTPException
4+
from sqlalchemy.ext.asyncio import AsyncSession
5+
import fastapi
6+
7+
from app.schemas.user import UserCreate, UserUpdate, UserRead, UserBase
8+
from app.api.dependencies import get_current_user
9+
from app.core.database import async_get_db
10+
from ...crud.crud_users import (
11+
get_user,
12+
get_user_by_email,
13+
get_users,
14+
create_user,
15+
get_user_by_username,
16+
update_user,
17+
delete_user
18+
)
19+
from app.api.dependencies import get_current_user
20+
21+
router = fastapi.APIRouter(tags=["users"])
22+
23+
@router.post("/user", response_model=UserBase, status_code=201)
24+
async def write_user(user: UserCreate, db: AsyncSession = Depends(async_get_db)):
25+
db_user = await get_user_by_email(db=db, email=user.email)
26+
if db_user:
27+
raise HTTPException(status_code=400, detail="Email is already registered")
28+
29+
db_user = await get_user_by_username(db=db, username=user.username)
30+
if db_user:
31+
raise HTTPException(status_code=400, detail="Username not available")
32+
33+
return await create_user(db=db, user=user)
34+
35+
36+
@router.get("/user", response_model=List[UserRead])
37+
async def read_users(db: AsyncSession = Depends(async_get_db)):
38+
users = await get_users(db=db)
39+
return users
40+
41+
42+
@router.get("/user/me/", response_model=UserRead)
43+
async def read_users_me(
44+
current_user: Annotated[UserRead, Depends(get_current_user)]
45+
):
46+
return current_user
47+
48+
49+
@router.get("/user/{id}", response_model=UserRead)
50+
async def read_user(id: int, db: AsyncSession = Depends(async_get_db)):
51+
db_user = await get_user(db=db, id=id)
52+
if db_user is None:
53+
raise HTTPException(status_code=404, detail="User not found")
54+
55+
return db_user
56+
57+
58+
@router.patch("/users/{id}", response_model=UserUpdate)
59+
async def patch_user(
60+
values: UserUpdate,
61+
id: int,
62+
current_user: Annotated[UserRead, Depends(get_current_user)],
63+
db: AsyncSession = Depends(async_get_db)
64+
):
65+
db_user = await get_user(db=db, id=id)
66+
if db_user is None:
67+
raise HTTPException(status_code=404, detail="User not found")
68+
69+
if db_user.id != current_user.id:
70+
raise HTTPException(status_code=403, detail="You don't own this user.")
71+
72+
if values.username != db_user.username:
73+
existing_username = await get_user_by_username(db=db, username=values.username)
74+
if existing_username is not None:
75+
raise HTTPException(status_code=400, detail="Username not available")
76+
77+
if values.email != db_user.email:
78+
existing_email = await get_user_by_email(db=db, email=values.email)
79+
if existing_email:
80+
raise HTTPException(status_code=400, detail="Email is already registered")
81+
82+
db_user = await update_user(db=db, id=id, values=values, user=db_user)
83+
return db_user
84+
85+
86+
@router.delete("/user/{id}")
87+
async def erase_user(
88+
id: int,
89+
current_user: Annotated[UserRead, Depends(get_current_user)],
90+
db: AsyncSession = Depends(async_get_db)
91+
):
92+
db_user = await get_user(db=db, id=id)
93+
if db_user is None:
94+
raise HTTPException(status_code=404, detail="User not found")
95+
96+
if db_user.id != current_user.id:
97+
raise HTTPException(status_code=403, detail="You don't own this user.")
98+
99+
db_user = await delete_user(db=db, id=id, user=db_user)
100+
return db_user

src/app/core/config.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from typing import Optional
2+
3+
from decouple import config
4+
from pydantic import SecretStr
5+
from pydantic_settings import BaseSettings
6+
7+
class AppSettings(BaseSettings):
8+
APP_NAME: str = config("APP_NAME")
9+
APP_DESCRIPTION: str = config("APP_DESCRIPTION")
10+
APP_VERSION: str = config("APP_VERSION")
11+
CONTACT: dict = {
12+
"name": config("CONTACT_NAME"),
13+
"email": config("CONTACT_EMAIL")
14+
}
15+
LICENSE_NAME: dict = {"name": config("LICENSE_NAME")}
16+
17+
18+
class CryptSettings(BaseSettings):
19+
SECRET_KEY: str = config("SECRET_KEY")
20+
ALGORITHM: str = config("ALGORITHM")
21+
ACCESS_TOKEN_EXPIRE_MINUTES: int = config("ACCESS_TOKEN_EXPIRE_MINUTES")
22+
23+
24+
class PostgresSettings(BaseSettings):
25+
POSTGRES_USER: str = config("POSTGRES_USER", default="postgres")
26+
POSTGRES_PASSWORD: SecretStr = config("POSTGRES_PASSWORD", default="postgres")
27+
POSTGRES_SERVER: str = config("POSTGRES_SERVER", default="localhost")
28+
POSTGRES_PORT: str = config("POSTGRES_PORT", default=5432)
29+
POSTGRES_DB: str = config("POSTGRES_DB", default="postgres")
30+
POSTGRES_URI: str = f"{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_SERVER}:{POSTGRES_PORT}/{POSTGRES_DB}"
31+
32+
33+
class Settings(AppSettings, PostgresSettings, CryptSettings):
34+
pass
35+
36+
37+
settings = Settings()

0 commit comments

Comments
 (0)