diff --git a/.dockerignore b/.dockerignore index 3f59559..1c20a3d 100755 --- a/.dockerignore +++ b/.dockerignore @@ -20,3 +20,7 @@ coverage.xml .github .hypothesis .venv +.DS_Store +build +dist +*.egg-info diff --git a/.env b/.env index 15b2973..c8b84c8 100755 --- a/.env +++ b/.env @@ -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"] diff --git a/README.md b/README.md index f0ee39b..8813914 100755 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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 @@ -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) ``` diff --git a/pyproject.toml b/pyproject.toml index c55157a..2401335 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = {} diff --git a/svc/__init__.py b/svc/__init__.py index ac1a21d..e69de29 100755 --- a/svc/__init__.py +++ b/svc/__init__.py @@ -1,3 +0,0 @@ -from svc.core.logger import configure_logger - -configure_logger() diff --git a/svc/apis/api_a/mainmod.py b/svc/apis/api_a/mainmod.py index accf2a4..ad87e9e 100755 --- a/svc/apis/api_a/mainmod.py +++ b/svc/apis/api_a/mainmod.py @@ -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) diff --git a/svc/apis/api_a/submod.py b/svc/apis/api_a/submod.py index aa11d63..0370580 100755 --- a/svc/apis/api_a/submod.py +++ b/svc/apis/api_a/submod.py @@ -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), + ) diff --git a/svc/apis/api_b/mainmod.py b/svc/apis/api_b/mainmod.py index accf2a4..ad87e9e 100755 --- a/svc/apis/api_b/mainmod.py +++ b/svc/apis/api_b/mainmod.py @@ -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) diff --git a/svc/apis/api_b/submod.py b/svc/apis/api_b/submod.py index aa11d63..0370580 100755 --- a/svc/apis/api_b/submod.py +++ b/svc/apis/api_b/submod.py @@ -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), + ) diff --git a/svc/apis/schemas.py b/svc/apis/schemas.py new file mode 100644 index 0000000..afba79a --- /dev/null +++ b/svc/apis/schemas.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class RandomNumbers(BaseModel): + seed: int + random_first: int + random_second: int diff --git a/svc/core/auth.py b/svc/core/auth.py index f84f218..cc8c2d4 100755 --- a/svc/core/auth.py +++ b/svc/core/auth.py @@ -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 @@ -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() @@ -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 @@ -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( @@ -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, @@ -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, @@ -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") diff --git a/svc/core/config.py b/svc/core/config.py index c1f639a..ae0b818 100755 --- a/svc/core/config.py +++ b/svc/core/config.py @@ -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") diff --git a/svc/core/logger.py b/svc/core/logger.py index b677304..7caf89d 100644 --- a/svc/core/logger.py +++ b/svc/core/logger.py @@ -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 diff --git a/svc/main.py b/svc/main.py index aec7b6b..deece16 100755 --- a/svc/main.py +++ b/svc/main.py @@ -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) diff --git a/svc/routes/views.py b/svc/routes/views.py index ea649ac..e4006fa 100755 --- a/svc/routes/views.py +++ b/svc/routes/views.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import logging from typing import Annotated @@ -7,10 +5,11 @@ 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)] @@ -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 @@ -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 diff --git a/svc/tests/test_apis.py b/svc/tests/test_apis.py index 06d96a8..e578613 100755 --- a/svc/tests/test_apis.py +++ b/svc/tests/test_apis.py @@ -144,3 +144,17 @@ def test_api_b_ok(client: TestClient, api_token: str) -> None: for val in response.json().values(): assert isinstance(val, int) + + +def test_cors_preflight_allows_configured_origin(client: TestClient) -> None: + response = client.options( + "/api_a/22", + headers={ + "Origin": "http://localhost:5002", + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "Authorization", + }, + ) + + assert response.status_code == HTTPStatus.OK + assert response.headers["access-control-allow-origin"] == "http://localhost:5002" diff --git a/svc/tests/test_functions.py b/svc/tests/test_functions.py index 9e584c9..4de202c 100755 --- a/svc/tests/test_functions.py +++ b/svc/tests/test_functions.py @@ -4,6 +4,7 @@ from svc.apis.api_a import mainmod as mainmod_a from svc.apis.api_b import mainmod as mainmod_b +from svc.apis.schemas import RandomNumbers @pytest.fixture(scope="module") @@ -28,10 +29,10 @@ def test_func_main_a(mock_randint: MagicMock, seed: int, output: int) -> None: result = mainmod_a.main_func(seed) # Assert. - assert isinstance(result, dict) - assert result["seed"] == seed - assert result["random_first"] == output - assert result["random_second"] == output + assert isinstance(result, RandomNumbers) + assert result.seed == seed + assert result.random_first == output + assert result.random_second == output mock_randint.assert_called_with(0, seed) @@ -50,9 +51,9 @@ def test_func_main_b(mock_randint: MagicMock, seed: int, output: int) -> None: result = mainmod_b.main_func(seed) # Assert. - assert isinstance(result, dict) - assert result["seed"] == seed - assert result["random_first"] == output - assert result["random_second"] == output + assert isinstance(result, RandomNumbers) + assert result.seed == seed + assert result.random_first == output + assert result.random_second == output mock_randint.assert_called_with(0, seed)