mirror of
https://github.com/grillazz/fastapi-sqlalchemy-asyncpg.git
synced 2025-08-26 16:40:40 +03:00
Merge pull request #100 from grillazz/99-jwt-auth-sample
99 jwt auth sample
This commit is contained in:
commit
9622a472d3
12
.env
12
.env
@ -8,5 +8,13 @@ SQL_USER=user
|
||||
SQL_PASS=secret
|
||||
SQL_URL=postgresql+asyncpg://${SQL_USER}:${SQL_PASS}@${SQL_HOST}/${SQL_DB}
|
||||
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_DB=2
|
||||
REDIS_URL="redis://${REDIS_HOST}:${REDIS_PORT}/${REDIS_DB}"
|
||||
|
||||
JWT_EXPIRE=3600
|
||||
JWT_ALGORITHM=HS256
|
||||
|
||||
|
||||
|
||||
|
11
.github/workflows/build-and-test.yml
vendored
11
.github/workflows/build-and-test.yml
vendored
@ -24,8 +24,19 @@ jobs:
|
||||
POSTGRES_PASSWORD: secret
|
||||
PGPASSWORD: secret
|
||||
SQL_URL: postgresql+asyncpg://app-user:secret@localhost:5432/testdb
|
||||
FERNET_KEY: Ms1HSn513x0_4WWFBQ3hYPDGAHpKH_pIseC5WwqyO7M=
|
||||
REDIS_HOST: 127.0.0.1
|
||||
REDIS_PORT: 6379
|
||||
REDIS_DB: 2
|
||||
REDIS_URL: redis://127.0.0.1:6379/2
|
||||
JWT_EXPIRE: 3600
|
||||
JWT_ALGORITHM: HS256
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:latest
|
||||
ports:
|
||||
- 6379:6379
|
||||
sqldb:
|
||||
image: postgres:14
|
||||
env:
|
||||
|
2
.secrets
2
.secrets
@ -1,2 +1,2 @@
|
||||
POSTGRES_PASSWORD=secret
|
||||
SECRET_KEY=key
|
||||
FERNET_KEY=Ms1HSn513x0_4WWFBQ3hYPDGAHpKH_pIseC5WwqyO7M=
|
35
README.md
35
README.md
@ -71,17 +71,18 @@ Below steps were done to integrate [rich](https://github.com/Textualize/rich) in
|
||||
|
||||

|
||||
|
||||
Hope you enjoy it.
|
||||
### User authentication with JWT and Redis as token storage :lock: :key:
|
||||
|
||||
#### Generate Fernet key for storing password in db
|
||||
```python
|
||||
In [1]: from cryptography.fernet import Fernet
|
||||
|
||||
In [2]: Fernet.generate_key()
|
||||
Out[2]: b'Ms1HSn513x0_4WWFBQ3hYPDGAHpKH_pIseC5WwqyO7M='
|
||||
|
||||
```
|
||||
Save the key in .secrets as FERNET_KEY
|
||||
|
||||
### Change Log
|
||||
- 4 JUN 2022 alembic migrations added to project
|
||||
- 6 JUN 2022 added initial dataset for shakespeare models
|
||||
- 3 OCT 2022 poetry added to project
|
||||
- 12 NOV 2022 ruff implemented to project as linting tool
|
||||
- 14 FEB 2023 bump project to Python 3.11
|
||||
- 10 APR 2023 implement logging with rich
|
||||
- 28 APR 2023 Rainbow logs with rich :rainbow:
|
||||
- 7 JUL 2023 migrate to pydantic 2.0
|
||||
|
||||
### Local development with poetry
|
||||
|
||||
@ -92,3 +93,17 @@ pyenv install 3.11 && pyenv local 3.11
|
||||
poetry install
|
||||
```
|
||||
|
||||
|
||||
Hope you enjoy it.
|
||||
|
||||
### Change Log
|
||||
- 4 JUN 2022 alembic migrations added to project
|
||||
- 6 JUN 2022 added initial dataset for shakespeare models
|
||||
- 3 OCT 2022 poetry added to project
|
||||
- 12 NOV 2022 ruff implemented to project as linting tool
|
||||
- 14 FEB 2023 bump project to Python 3.11
|
||||
- 10 APR 2023 implement logging with rich
|
||||
- 28 APR 2023 Rainbow logs with rich :rainbow:
|
||||
- 7 JUL 2023 migrate to pydantic 2.0 :fast_forward:
|
||||
- 25 JUL 2023 add user authentication with JWT and Redis as token storage :lock: :key:
|
||||
|
||||
|
@ -31,7 +31,7 @@ async def run_migrations_online():
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = create_async_engine(settings.asyncpg_url, future=True)
|
||||
connectable = create_async_engine(settings.asyncpg_url.unicode_string(), future=True)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
40
alembic/versions/20230722_1219_2dcc708f88f8_user_auth.py
Normal file
40
alembic/versions/20230722_1219_2dcc708f88f8_user_auth.py
Normal file
@ -0,0 +1,40 @@
|
||||
"""user auth
|
||||
|
||||
Revision ID: 2dcc708f88f8
|
||||
Revises: 0d1ee3949d21
|
||||
Create Date: 2023-07-22 12:19:28.780926
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2dcc708f88f8'
|
||||
down_revision = '0d1ee3949d21'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('user',
|
||||
sa.Column('uuid', sa.UUID(), nullable=False),
|
||||
sa.Column('email', sa.String(), nullable=False),
|
||||
sa.Column('first_name', sa.String(), nullable=False),
|
||||
sa.Column('last_name', sa.String(), nullable=False),
|
||||
sa.Column('password', sa.LargeBinary(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('uuid'),
|
||||
sa.UniqueConstraint('uuid')
|
||||
)
|
||||
op.create_unique_constraint(None, 'nonsense', ['name'], schema='happy_hog')
|
||||
op.create_unique_constraint(None, 'stuff', ['name'], schema='happy_hog')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, 'stuff', schema='happy_hog', type_='unique')
|
||||
op.drop_constraint(None, 'nonsense', schema='happy_hog', type_='unique')
|
||||
op.drop_table('user')
|
||||
# ### end Alembic commands ###
|
16
app/api/health.py
Normal file
16
app/api/health.py
Normal file
@ -0,0 +1,16 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, status, Request
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/redis", status_code=status.HTTP_200_OK)
|
||||
async def redis_check(request: Request):
|
||||
_redis = await request.app.state.redis
|
||||
_info = None
|
||||
try:
|
||||
_info = await _redis.info()
|
||||
except Exception as e:
|
||||
logging.error(f"Redis error: {e}")
|
||||
return _info
|
34
app/api/user.py
Normal file
34
app/api/user.py
Normal file
@ -0,0 +1,34 @@
|
||||
from fastapi import APIRouter, Depends, status, Request, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserSchema, UserResponse, UserLogin, TokenResponse
|
||||
from app.services.auth import create_access_token
|
||||
|
||||
router = APIRouter(prefix="/v1/user")
|
||||
|
||||
|
||||
@router.post("/", status_code=status.HTTP_201_CREATED, response_model=UserResponse)
|
||||
async def create_user(payload: UserSchema, request: Request, db_session: AsyncSession = Depends(get_db)):
|
||||
_user: User = User(**payload.model_dump())
|
||||
await _user.save(db_session)
|
||||
|
||||
# TODO: add refresh token
|
||||
_user.access_token = await create_access_token(_user, request)
|
||||
return _user
|
||||
|
||||
|
||||
@router.post("/token", status_code=status.HTTP_201_CREATED, response_model=TokenResponse)
|
||||
async def get_token_for_user(user: UserLogin, request: Request, db_session: AsyncSession = Depends(get_db)):
|
||||
_user: User = await User.find(db_session, [User.email == user.email])
|
||||
|
||||
# TODO: out exception handling to external module
|
||||
if not _user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
if not _user.check_password(user.password):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Password is incorrect")
|
||||
|
||||
# TODO: add refresh token
|
||||
_token = await create_access_token(_user, request)
|
||||
return {"access_token": _token, "token_type": "bearer"}
|
@ -1,12 +1,16 @@
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import PostgresDsn
|
||||
from pydantic import PostgresDsn, RedisDsn
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
asyncpg_url: PostgresDsn = os.getenv("SQL_URL")
|
||||
secret_key: str = os.getenv("FERNET_KEY")
|
||||
redis_url: RedisDsn = os.getenv("REDIS_URL")
|
||||
jwt_algorithm: str = os.getenv("JWT_ALGORITHM")
|
||||
jwt_expire: int = os.getenv("JWT_EXPIRE")
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
31
app/main.py
31
app/main.py
@ -1,24 +1,35 @@
|
||||
from fastapi import FastAPI
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Depends
|
||||
|
||||
from app.api.nonsense import router as nonsense_router
|
||||
from app.api.shakespeare import router as shakespeare_router
|
||||
from app.api.stuff import router as stuff_router
|
||||
from app.utils.logging import AppLogger
|
||||
from app.api.user import router as user_router
|
||||
from app.api.health import router as health_router
|
||||
from app.redis import get_redis
|
||||
from app.services.auth import AuthBearer
|
||||
|
||||
logger = AppLogger.__call__().get_logger()
|
||||
|
||||
app = FastAPI(title="Stuff And Nonsense API", version="0.5")
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Load the redis connection
|
||||
app.state.redis = await get_redis()
|
||||
yield
|
||||
# close redis connection and release the resources
|
||||
app.state.redis.close()
|
||||
|
||||
|
||||
app = FastAPI(title="Stuff And Nonsense API", version="0.6", lifespan=lifespan)
|
||||
|
||||
app.include_router(stuff_router)
|
||||
app.include_router(nonsense_router)
|
||||
app.include_router(shakespeare_router)
|
||||
app.include_router(user_router)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
logger.info("Starting up...")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
logger.info("Shutting down...")
|
||||
app.include_router(health_router, prefix="/v1/public/health", tags=["Health, Public"])
|
||||
app.include_router(health_router, prefix="/v1/health", tags=["Health, Bearer"], dependencies=[Depends(AuthBearer())])
|
||||
|
@ -2,3 +2,4 @@
|
||||
from app.models.nonsense import * # noqa
|
||||
from app.models.shakespeare import * # noqa
|
||||
from app.models.stuff import * # noqa
|
||||
from app.models.user import * # noqa
|
||||
|
50
app/models/user.py
Normal file
50
app/models/user.py
Normal file
@ -0,0 +1,50 @@
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from sqlalchemy import Column, String, LargeBinary, select
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import config
|
||||
from app.models.base import Base
|
||||
|
||||
global_settings = config.get_settings()
|
||||
|
||||
cipher_suite = Fernet(global_settings.secret_key)
|
||||
|
||||
|
||||
class User(Base): # type: ignore
|
||||
uuid = Column(
|
||||
UUID(as_uuid=True),
|
||||
unique=True,
|
||||
default=uuid.uuid4,
|
||||
primary_key=True,
|
||||
)
|
||||
email = Column(String, nullable=False)
|
||||
first_name = Column(String, nullable=False)
|
||||
last_name = Column(String, nullable=False)
|
||||
_password = Column("password", LargeBinary, nullable=False)
|
||||
|
||||
def __init__(self, email: str, first_name: str, last_name: str, password: str = None):
|
||||
self.email = email
|
||||
self.first_name = first_name
|
||||
self.last_name = last_name
|
||||
self.password = password
|
||||
|
||||
@property
|
||||
def password(self):
|
||||
return cipher_suite.decrypt(self._password).decode()
|
||||
|
||||
@password.setter
|
||||
def password(self, password: str):
|
||||
self._password = cipher_suite.encrypt(password.encode())
|
||||
|
||||
def check_password(self, password: str):
|
||||
return self.password == password
|
||||
|
||||
@classmethod
|
||||
async def find(cls, database_session: AsyncSession, where_conditions: list[Any]):
|
||||
_stmt = select(cls).where(*where_conditions)
|
||||
_result = await database_session.execute(_stmt)
|
||||
return _result.scalars().first()
|
14
app/redis.py
Normal file
14
app/redis.py
Normal file
@ -0,0 +1,14 @@
|
||||
import redis.asyncio as redis
|
||||
|
||||
from app import config
|
||||
|
||||
|
||||
global_settings = config.get_settings()
|
||||
|
||||
|
||||
async def get_redis():
|
||||
return await redis.from_url(
|
||||
global_settings.redis_url.unicode_string(),
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
)
|
@ -1,9 +1,12 @@
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class NonsenseSchema(BaseModel):
|
||||
model_config = config
|
||||
name: str = Field(
|
||||
title="",
|
||||
description="",
|
||||
@ -13,17 +16,18 @@ class NonsenseSchema(BaseModel):
|
||||
description="",
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"name": "Name for Some Nonsense",
|
||||
"description": "Some Nonsense Description",
|
||||
}
|
||||
}
|
||||
# class Config:
|
||||
# from_attributes = True
|
||||
# json_schema_extra = {
|
||||
# "example": {
|
||||
# "name": "Name for Some Nonsense",
|
||||
# "description": "Some Nonsense Description",
|
||||
# }
|
||||
# }
|
||||
|
||||
|
||||
class NonsenseResponse(BaseModel):
|
||||
model_config = config
|
||||
id: UUID = Field(
|
||||
title="Id",
|
||||
description="",
|
||||
@ -37,12 +41,12 @@ class NonsenseResponse(BaseModel):
|
||||
description="",
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"config_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
"name": "Name for Some Nonsense",
|
||||
"description": "Some Nonsense Description",
|
||||
}
|
||||
}
|
||||
# class Config:
|
||||
# from_attributes = True
|
||||
# json_schema_extra = {
|
||||
# "example": {
|
||||
# "config_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
# "name": "Name for Some Nonsense",
|
||||
# "description": "Some Nonsense Description",
|
||||
# }
|
||||
# }
|
||||
|
@ -1,6 +1,8 @@
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class StuffSchema(BaseModel):
|
||||
@ -13,17 +15,18 @@ class StuffSchema(BaseModel):
|
||||
description="",
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"name": "Name for Some Stuff",
|
||||
"description": "Some Stuff Description",
|
||||
}
|
||||
}
|
||||
# class Config:
|
||||
# from_attributes = True
|
||||
# json_schema_extra = {
|
||||
# "example": {
|
||||
# "name": "Name for Some Stuff",
|
||||
# "description": "Some Stuff Description",
|
||||
# }
|
||||
# }
|
||||
|
||||
|
||||
class StuffResponse(BaseModel):
|
||||
model_config = config
|
||||
id: UUID = Field(
|
||||
title="Id",
|
||||
description="",
|
||||
@ -37,12 +40,12 @@ class StuffResponse(BaseModel):
|
||||
description="",
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"config_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
"name": "Name for Some Stuff",
|
||||
"description": "Some Stuff Description",
|
||||
}
|
||||
}
|
||||
# class Config:
|
||||
# from_attributes = True
|
||||
# json_schema_extra = {
|
||||
# "example": {
|
||||
# "config_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
# "name": "Name for Some Stuff",
|
||||
# "description": "Some Stuff Description",
|
||||
# }
|
||||
# }
|
||||
|
33
app/schemas/user.py
Normal file
33
app/schemas/user.py
Normal file
@ -0,0 +1,33 @@
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, EmailStr, ConfigDict
|
||||
|
||||
config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# TODO: add pydantic field validator for strong password
|
||||
class UserSchema(BaseModel):
|
||||
model_config = config
|
||||
email: EmailStr = Field(title="User’s email", description="User’s email")
|
||||
first_name: str = Field(title="User’s first name", description="User’s first name")
|
||||
last_name: str = Field(title="User’s last name", description="User’s last name")
|
||||
password: str = Field(title="User’s password", description="User’s password")
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
uuid: UUID = Field(title="User’s id", description="User’s id")
|
||||
email: EmailStr = Field(title="User’s email", description="User’s email")
|
||||
first_name: str = Field(title="User’s first name", description="User’s first name")
|
||||
last_name: str = Field(title="User’s last name", description="User’s last name")
|
||||
access_token: str = Field(title="User’s token", description="User’s token")
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str = Field(title="User’s access token", description="User’s access token")
|
||||
token_type: str = Field(title="User’s token type", description="User’s token type")
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
model_config = config
|
||||
email: EmailStr = Field(title="User’s email", description="User’s email")
|
||||
password: str = Field(title="User’s password", description="User’s password")
|
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
47
app/services/auth.py
Normal file
47
app/services/auth.py
Normal file
@ -0,0 +1,47 @@
|
||||
import time
|
||||
import jwt
|
||||
|
||||
from app import config
|
||||
from app.models.user import User
|
||||
|
||||
from fastapi import Request, HTTPException
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
global_settings = config.get_settings()
|
||||
|
||||
|
||||
async def verify_jwt(request: Request, token: str) -> bool:
|
||||
_payload = await request.app.state.redis.get(token)
|
||||
if _payload:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
class AuthBearer(HTTPBearer):
|
||||
def __init__(self, auto_error: bool = True):
|
||||
super().__init__(auto_error=auto_error)
|
||||
|
||||
async def __call__(self, request: Request):
|
||||
credentials: HTTPAuthorizationCredentials = await super(AuthBearer, self).__call__(request)
|
||||
if credentials:
|
||||
if not credentials.scheme == "Bearer":
|
||||
raise HTTPException(status_code=403, detail="Invalid authentication scheme.")
|
||||
if not await verify_jwt(request, credentials.credentials):
|
||||
raise HTTPException(status_code=403, detail="Invalid token or expired token.")
|
||||
return credentials.credentials
|
||||
else:
|
||||
raise HTTPException(status_code=403, detail="Invalid authorization code.")
|
||||
|
||||
|
||||
async def create_access_token(user: User, request: Request):
|
||||
_payload = {
|
||||
"email": user.email,
|
||||
"expiry": time.time() + global_settings.jwt_expire,
|
||||
"platform": request.headers.get("User-Agent"),
|
||||
}
|
||||
_token = jwt.encode(_payload, str(user.password), algorithm=global_settings.jwt_algorithm)
|
||||
|
||||
_bool = await request.app.state.redis.set(_token, str(_payload), ex=global_settings.jwt_expire)
|
||||
if _bool:
|
||||
return _token
|
@ -2,7 +2,7 @@ version: '3.9'
|
||||
|
||||
services:
|
||||
app:
|
||||
image: fastapi-sqlalchemy-asyncpg_app
|
||||
container_name: fsap_app
|
||||
build: .
|
||||
env_file:
|
||||
- .env
|
||||
@ -22,9 +22,10 @@ services:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
|
||||
db:
|
||||
image: fastapi-sqlalchemy-asyncpg_db
|
||||
container_name: fsap_db
|
||||
build:
|
||||
context: ./db
|
||||
dockerfile: Dockerfile
|
||||
@ -46,5 +47,14 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:latest
|
||||
container_name: fsap_redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
env_file:
|
||||
- .env
|
||||
entrypoint: redis-server --appendonly yes
|
||||
|
||||
volumes:
|
||||
fastapi_postgres_data: {}
|
891
poetry.lock
generated
891
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "fastapi-sqlalchemy-asyncpg"
|
||||
version = "0.0.5"
|
||||
version = "0.0.6"
|
||||
description = ""
|
||||
authors = ["Jakub Miazek <the@grillazz.com>"]
|
||||
packages = []
|
||||
@ -11,7 +11,7 @@ python = "^3.11"
|
||||
alembic = "*"
|
||||
asyncpg = "*"
|
||||
httpx = "*"
|
||||
pydantic = "*"
|
||||
pydantic = {version = "*", extras = ["email"]}
|
||||
sqlalchemy = "*"
|
||||
fastapi = "*"
|
||||
uvicorn = { extras = ["standard"], version = "*" }
|
||||
@ -26,7 +26,10 @@ ipython = "*"
|
||||
pytest-cov = "*"
|
||||
pytest-asyncio = "*"
|
||||
ruff = "*"
|
||||
pydantic-settings = "^2.0.1"
|
||||
pydantic-settings = "*"
|
||||
cryptography = "*"
|
||||
pyjwt = {version = "*", extras = ["cryptography"]}
|
||||
redis = "*"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
tryceratops = "^1.1.0"
|
||||
|
32
tests/api/test_auth.py
Normal file
32
tests/api/test_auth.py
Normal file
@ -0,0 +1,32 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from starlette import status
|
||||
import jwt
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
# TODO: parametrize test with diff urls
|
||||
async def test_add_user(client: AsyncClient):
|
||||
payload = {"email": "rancher@grassroots.com", "first_name": "Joe", "last_name": "Garcia", "password": "s1lly"}
|
||||
response = await client.post("/user/", json=payload)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
claimset = jwt.decode(response.json()["access_token"], options={"verify_signature": False})
|
||||
assert claimset["email"] == payload["email"]
|
||||
assert claimset["expiry"] > 0
|
||||
assert claimset["platform"] == "python-httpx/0.24.1"
|
||||
|
||||
|
||||
# TODO: parametrize test with diff urls including 404 and 401
|
||||
async def test_get_token(client: AsyncClient):
|
||||
payload = {"email": "rancher@grassroots.com", "password": "s1lly"}
|
||||
response = await client.post("/user/token", json=payload)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
claimset = jwt.decode(response.json()["access_token"], options={"verify_signature": False})
|
||||
assert claimset["email"] == payload["email"]
|
||||
assert claimset["expiry"] > 0
|
||||
assert claimset["platform"] == "python-httpx/0.24.1"
|
||||
|
||||
|
||||
# TODO: baerer token test
|
||||
# TODO: > get token > test endpoint auth with token > expire token on redis > test endpoint auth with token
|
12
tests/api/test_health.py
Normal file
12
tests/api/test_health.py
Normal file
@ -0,0 +1,12 @@
|
||||
import pytest
|
||||
from fastapi import status
|
||||
from httpx import AsyncClient
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
async def test_redis_health(client: AsyncClient):
|
||||
response = await client.get("/public/health/redis")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
# assert payload["name"] == response.json()["name"]
|
||||
# assert UUID(response.json()["id"])
|
@ -26,7 +26,7 @@ async def test_add_stuff(client: AsyncClient, payload: dict, status_code: int):
|
||||
"payload, status_code",
|
||||
(
|
||||
(
|
||||
{"name": "motorhead", "description": "we play rock and roll"},
|
||||
{"name": "motorhead-0", "description": "we play rock and roll"},
|
||||
status.HTTP_200_OK,
|
||||
),
|
||||
),
|
||||
@ -44,13 +44,14 @@ async def test_get_stuff(client: AsyncClient, payload: dict, status_code: int):
|
||||
"payload, status_code",
|
||||
(
|
||||
(
|
||||
{"name": "motorhead", "description": "we play rock and roll"},
|
||||
{"name": "motorhead-1", "description": "we play rock and roll"},
|
||||
status.HTTP_200_OK,
|
||||
),
|
||||
),
|
||||
)
|
||||
async def test_delete_stuff(client: AsyncClient, payload: dict, status_code: int):
|
||||
response = await client.post("/stuff", json=payload)
|
||||
print(response.json())
|
||||
name = response.json()["name"]
|
||||
response = await client.delete(f"/stuff/{name}")
|
||||
assert response.status_code == status_code
|
||||
|
@ -1,21 +1,23 @@
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.database import engine
|
||||
from app.main import app
|
||||
from app.models.base import Base
|
||||
from app.redis import get_redis
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
scope="session",
|
||||
params=[
|
||||
pytest.param(("asyncio", {"use_uvloop": True}), id="asyncio+uvloop"),
|
||||
]
|
||||
],
|
||||
)
|
||||
def anyio_backend(request):
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def start_db():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
@ -25,15 +27,12 @@ async def start_db():
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client() -> AsyncClient:
|
||||
@pytest.fixture(scope="session")
|
||||
async def client(start_db) -> AsyncClient:
|
||||
async with AsyncClient(
|
||||
app=app,
|
||||
base_url="http://testserver/v1",
|
||||
headers={"Content-Type": "application/json"},
|
||||
) as client:
|
||||
await start_db()
|
||||
yield client
|
||||
# for AsyncEngine created in function scope, close and
|
||||
# clean-up pooled connections
|
||||
await engine.dispose()
|
||||
) as test_client:
|
||||
app.state.redis = await get_redis()
|
||||
yield test_client
|
||||
|
Loading…
x
Reference in New Issue
Block a user