Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,7 @@ coverage.xml
.github
.hypothesis
.venv
.DS_Store
build
dist
*.egg-info
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ API_PASSWORD=debian
API_SECRET_KEY=09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7
API_ALGORITHM=HS256
API_ACCESS_TOKEN_EXPIRE_MINUTES=30
CORS_ALLOW_ORIGINS=["http://localhost","http://localhost:5002","http://127.0.0.1","http://127.0.0.1:5002"]
47 changes: 24 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ directory structure.
- OAuth2 authentication with [Argon2][argon2] password hashing via [pwdlib][pwdlib]
and Bearer JWT tokens via [PyJWT][pyjwt].

- [CORS (Cross Origin Resource Sharing)][cors] enabled.
- [CORS (Cross Origin Resource Sharing)][cors] enabled with explicit allowed origins
so credentialed requests follow FastAPI's current CORS guidance.

- Flask inspired divisional directory structure, suitable for small to medium backend
development.
Expand Down Expand Up @@ -128,6 +129,9 @@ If you want to run the app locally, without using Docker, then:
- Lint with [ruff] and check types with [mypy] using `make lint`.
- Update dependencies with `make dep-update`.
- Stop containers with `make kill-container`.
- Configure credentialed CORS origins with `CORS_ALLOW_ORIGINS` in `.env`. The value is
parsed as a JSON array, for example
`["http://localhost","http://localhost:5002"]`.

## Directory structure

Expand All @@ -141,10 +145,11 @@ fastapi-nano
│ │ │ ├── __init__.py # empty init file to make the api_a folder a package
│ │ │ ├── mainmod.py # main module of api_a package
│ │ │ └── submod.py # submodule of api_a package
│ │ └── api_b # api_b package
│ │ ├── __init__.py # empty init file to make the api_b folder a package
│ │ ├── mainmod.py # main module of api_b package
│ │ └── submod.py # submodule of api_b package
│ │ ├── api_b # api_b package
│ │ │ ├── __init__.py # empty init file to make the api_b folder a package
│ │ │ ├── mainmod.py # main module of api_b package
│ │ │ └── submod.py # submodule of api_b package
│ │ └── schemas.py # shared Pydantic response models
│ ├── core # this is where the configs live
│ │ ├── auth.py # authentication with OAuth2
│ │ ├── config.py # typed environment settings
Expand Down Expand Up @@ -175,54 +180,50 @@ then assemble their endpoints in the routes directory. The following snippets sh
behind the dummy APIs.

This is a dummy submodule that houses a function called `rand_gen` which generates a
dictionary of random integers.
Pydantic response model with random integers.

```python
# This is a dummy module.
# This gets called in mainmod.py.
from __future__ import annotations
import random

from svc.apis.schemas import RandomNumbers

def rand_gen(num: int) -> dict[str, int]:

def rand_gen(num: int) -> RandomNumbers:
num = int(num)
d = {
"seed": num,
"random_first": random.randint(0, num),
"random_second": random.randint(0, num),
}
return d
return RandomNumbers(
seed=num,
random_first=random.randint(0, num),
random_second=random.randint(0, num),
)
```

The `main_func` in the primary module calls the `rand_gen` function from the submodule.

```python
from __future__ import annotations
from svc.apis.schemas import RandomNumbers
from svc.apis.api_a.submod import rand_gen


def main_func(num: int) -> dict[str, int]:
d = rand_gen(num)
return d
def main_func(num: int) -> RandomNumbers:
return rand_gen(num)
```

The endpoint is exposed like this:

```python
# svc/routes/views.py
from __future__ import annotations

from typing import Annotated

from fastapi import Depends

from svc.apis.schemas import RandomNumbers
from svc.core.auth import UserInDB, get_current_user

CurrentUser = Annotated[UserInDB, Depends(get_current_user)]

# endpoint for api_a (api_b looks identical)
@router.get("/api_a/{num}", tags=["api_a"])
async def view_a(num: int, _auth: CurrentUser) -> dict[str, int]:
async def view_a(num: int, _auth: CurrentUser) -> RandomNumbers:
return main_func_a(num)
```

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ target-version = "py313"

[tool.ruff.lint]
# Enable Pyflakes `E` and `F` codes by default
select = ["E", "F", "PT", "C4", "I"]
select = ["E", "F", "PT", "C4", "I", "UP"]
ignore = ["E501"]

per-file-ignores = {}
Expand Down
3 changes: 0 additions & 3 deletions svc/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +0,0 @@
from svc.core.logger import configure_logger

configure_logger()
7 changes: 3 additions & 4 deletions svc/apis/api_a/mainmod.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
from __future__ import annotations
from svc.apis.schemas import RandomNumbers

from .submod import rand_gen


def main_func(num: int) -> dict[str, int]:
d = rand_gen(num)
return d
def main_func(num: int) -> RandomNumbers:
return rand_gen(num)
17 changes: 8 additions & 9 deletions svc/apis/api_a/submod.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
# This is a dummy module.
# This gets called in mainmod.py.

from __future__ import annotations

import random

from svc.apis.schemas import RandomNumbers


def rand_gen(num: int) -> dict[str, int]:
def rand_gen(num: int) -> RandomNumbers:
num = int(num)
d = {
"seed": num,
"random_first": random.randint(0, num),
"random_second": random.randint(0, num),
}
return d
return RandomNumbers(
seed=num,
random_first=random.randint(0, num),
random_second=random.randint(0, num),
)
7 changes: 3 additions & 4 deletions svc/apis/api_b/mainmod.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
from __future__ import annotations
from svc.apis.schemas import RandomNumbers

from .submod import rand_gen


def main_func(num: int) -> dict[str, int]:
d = rand_gen(num)
return d
def main_func(num: int) -> RandomNumbers:
return rand_gen(num)
17 changes: 8 additions & 9 deletions svc/apis/api_b/submod.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
# This is a dummy module.
# This gets called in mainmod.py.

from __future__ import annotations

import random

from svc.apis.schemas import RandomNumbers


def rand_gen(num: int) -> dict[str, int]:
def rand_gen(num: int) -> RandomNumbers:
num = int(num)
d = {
"seed": num,
"random_first": random.randint(0, num),
"random_second": random.randint(0, num),
}
return d
return RandomNumbers(
seed=num,
random_first=random.randint(0, num),
random_second=random.randint(0, num),
)
7 changes: 7 additions & 0 deletions svc/apis/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from pydantic import BaseModel


class RandomNumbers(BaseModel):
seed: int
random_first: int
random_second: int
32 changes: 18 additions & 14 deletions svc/core/auth.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
from __future__ import annotations

from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from functools import lru_cache
from typing import Annotated, Any
from typing import Annotated, Any, TypedDict

import jwt
from fastapi import APIRouter, Depends, HTTPException, status
Expand Down Expand Up @@ -32,11 +30,16 @@ class UserInDB(User):
hashed_password: str


type UserRecord = dict[str, str]
class UserRecord(TypedDict):
username: str
hashed_password: str


pwd_context = PasswordHash.recommended()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token")
DUMMY_PASSWORD_HASH = pwd_context.hash("dummypassword")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
SettingsDep = Annotated[Settings, Depends(get_settings)]
OAuth2Token = Annotated[str, Depends(oauth2_scheme)]
router = APIRouter()


Expand Down Expand Up @@ -72,6 +75,7 @@ def authenticate_user(
) -> UserInDB | None:
user = get_user(fake_db, username)
if not user:
verify_password(password, DUMMY_PASSWORD_HASH)
return None
if not verify_password(password, user.hashed_password):
return None
Expand All @@ -87,9 +91,9 @@ def create_access_token(
to_encode = data.copy()

if expires_delta:
expire = datetime.now(tz=timezone.utc) + expires_delta
expire = datetime.now(tz=UTC) + expires_delta
else:
expire = datetime.now(tz=timezone.utc) + timedelta(minutes=15)
expire = datetime.now(tz=UTC) + timedelta(minutes=15)

to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(
Expand All @@ -101,8 +105,8 @@ def create_access_token(


def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
settings: Annotated[Settings, Depends(get_settings)],
token: OAuth2Token,
settings: SettingsDep,
) -> UserInDB:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
Expand Down Expand Up @@ -135,11 +139,11 @@ def get_current_user(
return user


@router.post("/token", response_model=Token)
@router.post("/token")
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
settings: Annotated[Settings, Depends(get_settings)],
) -> dict[str, str]:
settings: SettingsDep,
) -> Token:
user = authenticate_user(
get_fake_users_db(settings.api_username, settings.api_password),
form_data.username,
Expand All @@ -162,4 +166,4 @@ async def login_for_access_token(
algorithm=settings.api_algorithm,
expires_delta=access_token_expires,
)
return {"access_token": access_token, "token_type": "bearer"}
return Token(access_token=access_token, token_type="bearer")
6 changes: 6 additions & 0 deletions svc/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ class Settings(BaseSettings):
api_secret_key: str
api_algorithm: str = "HS256"
api_access_token_expire_minutes: int
cors_allow_origins: list[str] = [
"http://localhost",
"http://localhost:5002",
"http://127.0.0.1",
"http://127.0.0.1:5002",
]

model_config = SettingsConfigDict(env_file=BASE_DIR / ".env", extra="ignore")

Expand Down
2 changes: 1 addition & 1 deletion svc/core/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def configure_logger() -> None:
console_handler.setFormatter(formatter)

# Add the handler to the logger
if not logger.hasHandlers():
if not logger.handlers:
logger.addHandler(console_handler)

# Disable propagation to avoid log duplication via uvicorn
Expand Down
11 changes: 7 additions & 4 deletions svc/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,24 @@
from fastapi.middleware.cors import CORSMiddleware

from svc.core import auth
from svc.core.config import get_settings
from svc.core.logger import configure_logger
from svc.routes import views


def create_app() -> FastAPI:
"""Create a FastAPI application."""

configure_logger()
settings = get_settings()
app = FastAPI()

# Set all CORS enabled origins
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=settings.cors_allow_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["Accept", "Authorization", "Content-Type"],
)

app.include_router(auth.router)
Expand Down
9 changes: 4 additions & 5 deletions svc/routes/views.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
from __future__ import annotations

import logging
from typing import Annotated

from fastapi import APIRouter, Depends

from svc.apis.api_a.mainmod import main_func as main_func_a
from svc.apis.api_b.mainmod import main_func as main_func_b
from svc.apis.schemas import RandomNumbers
from svc.core.auth import UserInDB, get_current_user

router = APIRouter()
logger = logging.getLogger(__name__)
logger = logging.getLogger("fnano")
CurrentUser = Annotated[UserInDB, Depends(get_current_user)]


Expand All @@ -26,7 +25,7 @@ async def index() -> dict[str, str]:
async def view_a(
num: int,
_auth: CurrentUser,
) -> dict[str, int]:
) -> RandomNumbers:
result = main_func_a(num)
logger.info(f"API A: {result}")
return result
Expand All @@ -36,7 +35,7 @@ async def view_a(
async def view_b(
num: int,
_auth: CurrentUser,
) -> dict[str, int]:
) -> RandomNumbers:
result = main_func_b(num)
logger.info(f"API B: {result}")
return result
Loading