fastapi-forge-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_forge/__init__.py +7 -0
- fastapi_forge/__main__.py +6 -0
- fastapi_forge/cli.py +211 -0
- fastapi_forge/templates/with_rbac/Dockerfile +31 -0
- fastapi_forge/templates/with_rbac/README.md +121 -0
- fastapi_forge/templates/with_rbac/_dockerignore +16 -0
- fastapi_forge/templates/with_rbac/_github/workflows/ci.yml +23 -0
- fastapi_forge/templates/with_rbac/_gitignore +19 -0
- fastapi_forge/templates/with_rbac/alembic/README +1 -0
- fastapi_forge/templates/with_rbac/alembic/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/alembic/env.py +51 -0
- fastapi_forge/templates/with_rbac/alembic/script.py.mako +28 -0
- fastapi_forge/templates/with_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +204 -0
- fastapi_forge/templates/with_rbac/alembic.ini +35 -0
- fastapi_forge/templates/with_rbac/app/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/api.py +34 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/repository.py +38 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/router.py +50 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/schema.py +21 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/__init__.py +0 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/repository.py +179 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/router.py +209 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/schema.py +98 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/service.py +383 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/__init__.py +3 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/router.py +23 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/schema.py +5 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/service.py +25 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/__init__.py +0 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/repository.py +80 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/router.py +151 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/schema.py +40 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/service.py +156 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/repository.py +161 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/router.py +169 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/schema.py +51 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/service.py +319 -0
- fastapi_forge/templates/with_rbac/app/api/v1/schema.py +7 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/repository.py +181 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/router.py +146 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/schema.py +112 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/service.py +291 -0
- fastapi_forge/templates/with_rbac/app/core/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/core/config.py +131 -0
- fastapi_forge/templates/with_rbac/app/core/dependencies.py +131 -0
- fastapi_forge/templates/with_rbac/app/core/exceptions.py +162 -0
- fastapi_forge/templates/with_rbac/app/core/logging.py +231 -0
- fastapi_forge/templates/with_rbac/app/core/middleware.py +188 -0
- fastapi_forge/templates/with_rbac/app/core/responses.py +108 -0
- fastapi_forge/templates/with_rbac/app/core/security.py +115 -0
- fastapi_forge/templates/with_rbac/app/db/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/db/base.py +5 -0
- fastapi_forge/templates/with_rbac/app/db/models/__init__.py +16 -0
- fastapi_forge/templates/with_rbac/app/db/models/audit_log.py +58 -0
- fastapi_forge/templates/with_rbac/app/db/models/auth_token.py +72 -0
- fastapi_forge/templates/with_rbac/app/db/models/notification.py +49 -0
- fastapi_forge/templates/with_rbac/app/db/models/permission.py +174 -0
- fastapi_forge/templates/with_rbac/app/db/models/revoked_token.py +21 -0
- fastapi_forge/templates/with_rbac/app/db/models/user.py +53 -0
- fastapi_forge/templates/with_rbac/app/db/schemas/__init__.py +8 -0
- fastapi_forge/templates/with_rbac/app/db/schemas/common.py +70 -0
- fastapi_forge/templates/with_rbac/app/db/schemas/names.py +9 -0
- fastapi_forge/templates/with_rbac/app/db/session.py +86 -0
- fastapi_forge/templates/with_rbac/app/helper/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/helper/pagination_helper.py +44 -0
- fastapi_forge/templates/with_rbac/app/helper/search.py +51 -0
- fastapi_forge/templates/with_rbac/app/helper/sorting.py +77 -0
- fastapi_forge/templates/with_rbac/app/main.py +66 -0
- fastapi_forge/templates/with_rbac/app/repositories/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/repositories/base.py +347 -0
- fastapi_forge/templates/with_rbac/app/services/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/services/audit.py +58 -0
- fastapi_forge/templates/with_rbac/app/services/email.py +118 -0
- fastapi_forge/templates/with_rbac/app/services/notification.py +82 -0
- fastapi_forge/templates/with_rbac/app/templates/email/notification.html +7 -0
- fastapi_forge/templates/with_rbac/app/templates/email/password_reset.html +7 -0
- fastapi_forge/templates/with_rbac/app/templates/email/verify_email.html +7 -0
- fastapi_forge/templates/with_rbac/app/templates/email/welcome.html +6 -0
- fastapi_forge/templates/with_rbac/app/utils/casing.py +31 -0
- fastapi_forge/templates/with_rbac/compose.yaml +33 -0
- fastapi_forge/templates/with_rbac/pyproject.toml +14 -0
- fastapi_forge/templates/with_rbac/requirements-dev.txt +5 -0
- fastapi_forge/templates/with_rbac/requirements.txt +16 -0
- fastapi_forge/templates/with_rbac/sample.env +42 -0
- fastapi_forge/templates/with_rbac/scripts/seed_first_user.py +166 -0
- fastapi_forge/templates/with_rbac/tests/test_audit.py +42 -0
- fastapi_forge/templates/with_rbac/tests/test_config.py +27 -0
- fastapi_forge/templates/with_rbac/tests/test_generator.py +20 -0
- fastapi_forge/templates/with_rbac/tests/test_permissions.py +36 -0
- fastapi_forge/templates/with_rbac/tests/test_security.py +68 -0
- fastapi_forge/templates/without_rbac/Dockerfile +31 -0
- fastapi_forge/templates/without_rbac/README.md +106 -0
- fastapi_forge/templates/without_rbac/_dockerignore +16 -0
- fastapi_forge/templates/without_rbac/_github/workflows/ci.yml +23 -0
- fastapi_forge/templates/without_rbac/_gitignore +19 -0
- fastapi_forge/templates/without_rbac/alembic/README +1 -0
- fastapi_forge/templates/without_rbac/alembic/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/alembic/env.py +51 -0
- fastapi_forge/templates/without_rbac/alembic/script.py.mako +28 -0
- fastapi_forge/templates/without_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +125 -0
- fastapi_forge/templates/without_rbac/alembic.ini +35 -0
- fastapi_forge/templates/without_rbac/app/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/api.py +29 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/repository.py +38 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/router.py +45 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/schema.py +21 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/__init__.py +0 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/repository.py +127 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/router.py +207 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/schema.py +96 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/service.py +373 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/__init__.py +3 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/router.py +23 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/schema.py +5 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/service.py +25 -0
- fastapi_forge/templates/without_rbac/app/api/v1/schema.py +7 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/repository.py +69 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/router.py +94 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/schema.py +50 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/service.py +92 -0
- fastapi_forge/templates/without_rbac/app/core/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/core/config.py +131 -0
- fastapi_forge/templates/without_rbac/app/core/dependencies.py +71 -0
- fastapi_forge/templates/without_rbac/app/core/exceptions.py +162 -0
- fastapi_forge/templates/without_rbac/app/core/logging.py +231 -0
- fastapi_forge/templates/without_rbac/app/core/middleware.py +188 -0
- fastapi_forge/templates/without_rbac/app/core/responses.py +108 -0
- fastapi_forge/templates/without_rbac/app/core/security.py +115 -0
- fastapi_forge/templates/without_rbac/app/db/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/db/base.py +5 -0
- fastapi_forge/templates/without_rbac/app/db/models/__init__.py +10 -0
- fastapi_forge/templates/without_rbac/app/db/models/audit_log.py +58 -0
- fastapi_forge/templates/without_rbac/app/db/models/auth_token.py +72 -0
- fastapi_forge/templates/without_rbac/app/db/models/notification.py +49 -0
- fastapi_forge/templates/without_rbac/app/db/models/revoked_token.py +21 -0
- fastapi_forge/templates/without_rbac/app/db/models/user.py +33 -0
- fastapi_forge/templates/without_rbac/app/db/schemas/__init__.py +8 -0
- fastapi_forge/templates/without_rbac/app/db/schemas/common.py +70 -0
- fastapi_forge/templates/without_rbac/app/db/schemas/names.py +5 -0
- fastapi_forge/templates/without_rbac/app/db/session.py +86 -0
- fastapi_forge/templates/without_rbac/app/helper/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/helper/pagination_helper.py +44 -0
- fastapi_forge/templates/without_rbac/app/helper/search.py +51 -0
- fastapi_forge/templates/without_rbac/app/helper/sorting.py +77 -0
- fastapi_forge/templates/without_rbac/app/main.py +66 -0
- fastapi_forge/templates/without_rbac/app/repositories/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/repositories/base.py +347 -0
- fastapi_forge/templates/without_rbac/app/services/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/services/audit.py +58 -0
- fastapi_forge/templates/without_rbac/app/services/email.py +118 -0
- fastapi_forge/templates/without_rbac/app/services/notification.py +82 -0
- fastapi_forge/templates/without_rbac/app/templates/email/notification.html +7 -0
- fastapi_forge/templates/without_rbac/app/templates/email/password_reset.html +7 -0
- fastapi_forge/templates/without_rbac/app/templates/email/verify_email.html +7 -0
- fastapi_forge/templates/without_rbac/app/templates/email/welcome.html +6 -0
- fastapi_forge/templates/without_rbac/app/utils/casing.py +31 -0
- fastapi_forge/templates/without_rbac/compose.yaml +33 -0
- fastapi_forge/templates/without_rbac/pyproject.toml +14 -0
- fastapi_forge/templates/without_rbac/requirements-dev.txt +5 -0
- fastapi_forge/templates/without_rbac/requirements.txt +16 -0
- fastapi_forge/templates/without_rbac/sample.env +42 -0
- fastapi_forge/templates/without_rbac/scripts/seed_first_user.py +51 -0
- fastapi_forge/templates/without_rbac/tests/test_audit.py +42 -0
- fastapi_forge/templates/without_rbac/tests/test_config.py +27 -0
- fastapi_forge/templates/without_rbac/tests/test_generator.py +20 -0
- fastapi_forge/templates/without_rbac/tests/test_security.py +68 -0
- fastapi_forge_cli-0.1.0.dist-info/METADATA +225 -0
- fastapi_forge_cli-0.1.0.dist-info/RECORD +181 -0
- fastapi_forge_cli-0.1.0.dist-info/WHEEL +5 -0
- fastapi_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
- fastapi_forge_cli-0.1.0.dist-info/licenses/LICENSE +18 -0
- fastapi_forge_cli-0.1.0.dist-info/top_level.txt +1 -0
fastapi_forge/cli.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Command-line interface for FastAPI Forge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from importlib import resources
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
PACKAGE = "fastapi_forge"
|
|
17
|
+
TEMPLATE_SENTINEL = "__PROJECT_NAME__"
|
|
18
|
+
DATABASE_SENTINEL = "project_name"
|
|
19
|
+
IGNORED_TEMPLATE_NAMES = {
|
|
20
|
+
".mypy_cache",
|
|
21
|
+
".pytest_cache",
|
|
22
|
+
".ruff_cache",
|
|
23
|
+
"__pycache__",
|
|
24
|
+
}
|
|
25
|
+
EXAMPLES = """Examples:
|
|
26
|
+
fastapi-forge create "Inventory API" --with-rbac
|
|
27
|
+
fastapi-forge create "Public API" --without-rbac
|
|
28
|
+
fastapi-forge create "Billing API" --with-rbac --output-dir ./projects
|
|
29
|
+
fastapi-forge create "Inventory API" --with-rbac --force
|
|
30
|
+
|
|
31
|
+
Run 'fastapi-forge create --help' for all project-generation options.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ForgeError(ValueError):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def project_slug(value: str) -> str:
|
|
40
|
+
slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", value.strip()).strip("-_").lower()
|
|
41
|
+
if not slug:
|
|
42
|
+
raise ForgeError("Project name must contain a letter or number.")
|
|
43
|
+
return slug
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ask_for_rbac(default: bool = True) -> bool:
|
|
47
|
+
suffix = "Y/n" if default else "y/N"
|
|
48
|
+
while True:
|
|
49
|
+
answer = input(f"Include role-based access control? [{suffix}] ").strip().lower()
|
|
50
|
+
if not answer:
|
|
51
|
+
return default
|
|
52
|
+
if answer in {"y", "yes"}:
|
|
53
|
+
return True
|
|
54
|
+
if answer in {"n", "no"}:
|
|
55
|
+
return False
|
|
56
|
+
print("Please answer yes or no.")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def resolve_rbac(args: argparse.Namespace) -> bool:
|
|
60
|
+
if args.with_rbac:
|
|
61
|
+
return True
|
|
62
|
+
if args.without_rbac:
|
|
63
|
+
return False
|
|
64
|
+
return ask_for_rbac() if sys.stdin.isatty() else True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def copy_resource_tree(source, destination: Path) -> None:
|
|
68
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
for item in source.iterdir():
|
|
70
|
+
if item.name in IGNORED_TEMPLATE_NAMES or item.name.endswith((".pyc", ".pyo")):
|
|
71
|
+
continue
|
|
72
|
+
target_name = {
|
|
73
|
+
"_gitignore": ".gitignore",
|
|
74
|
+
"_dockerignore": ".dockerignore",
|
|
75
|
+
"_github": ".github",
|
|
76
|
+
}.get(item.name, item.name)
|
|
77
|
+
target = destination / target_name
|
|
78
|
+
if item.is_dir():
|
|
79
|
+
copy_resource_tree(item, target)
|
|
80
|
+
else:
|
|
81
|
+
target.write_bytes(item.read_bytes())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def personalize(destination: Path, display_name: str, slug: str) -> None:
|
|
85
|
+
replacements = {
|
|
86
|
+
TEMPLATE_SENTINEL: display_name,
|
|
87
|
+
DATABASE_SENTINEL: slug.replace("-", "_"),
|
|
88
|
+
}
|
|
89
|
+
for relative in ("README.md", "sample.env", "app/core/config.py"):
|
|
90
|
+
path = destination / relative
|
|
91
|
+
text = path.read_text(encoding="utf-8")
|
|
92
|
+
for old, new in replacements.items():
|
|
93
|
+
text = text.replace(old, new)
|
|
94
|
+
path.write_text(text, encoding="utf-8")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def create_project(
|
|
98
|
+
name: str,
|
|
99
|
+
output_dir: Path,
|
|
100
|
+
with_rbac: bool,
|
|
101
|
+
force: bool = False,
|
|
102
|
+
) -> Path:
|
|
103
|
+
slug = project_slug(name)
|
|
104
|
+
parent = output_dir.expanduser().resolve()
|
|
105
|
+
destination = parent / slug
|
|
106
|
+
|
|
107
|
+
if destination.exists():
|
|
108
|
+
if not force:
|
|
109
|
+
raise ForgeError(
|
|
110
|
+
f"Destination already exists: {destination}. Use --force to replace it."
|
|
111
|
+
)
|
|
112
|
+
if destination == Path.cwd().resolve():
|
|
113
|
+
raise ForgeError("Refusing to replace the current working directory.")
|
|
114
|
+
if destination.is_symlink():
|
|
115
|
+
raise ForgeError("Refusing to replace a symbolic-link destination.")
|
|
116
|
+
|
|
117
|
+
variant = "with_rbac" if with_rbac else "without_rbac"
|
|
118
|
+
template = resources.files(PACKAGE).joinpath("templates", variant)
|
|
119
|
+
if not template.is_dir():
|
|
120
|
+
raise ForgeError(f"Packaged template is missing: {variant}")
|
|
121
|
+
|
|
122
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
staging = Path(tempfile.mkdtemp(prefix=f".{slug}-", dir=parent))
|
|
124
|
+
backup: Path | None = None
|
|
125
|
+
try:
|
|
126
|
+
copy_resource_tree(template, staging)
|
|
127
|
+
personalize(staging, name.strip(), slug)
|
|
128
|
+
if destination.exists():
|
|
129
|
+
backup = parent / f".{slug}.backup-{uuid.uuid4().hex}"
|
|
130
|
+
os.replace(destination, backup)
|
|
131
|
+
os.replace(staging, destination)
|
|
132
|
+
if backup is not None:
|
|
133
|
+
shutil.rmtree(backup)
|
|
134
|
+
except Exception:
|
|
135
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
136
|
+
if backup is not None and backup.exists() and not destination.exists():
|
|
137
|
+
os.replace(backup, destination)
|
|
138
|
+
raise
|
|
139
|
+
return destination
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
143
|
+
parser = argparse.ArgumentParser(
|
|
144
|
+
prog="fastapi-forge",
|
|
145
|
+
description="Generate ready-to-run, production-oriented FastAPI projects.",
|
|
146
|
+
epilog="Run 'fastapi-forge examples' to see common commands.",
|
|
147
|
+
)
|
|
148
|
+
parser.add_argument("--version", action="version", version="%(prog)s 0.1.0")
|
|
149
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
150
|
+
|
|
151
|
+
create = commands.add_parser(
|
|
152
|
+
"create",
|
|
153
|
+
help="Create a new FastAPI project.",
|
|
154
|
+
description="Generate a FastAPI project with PostgreSQL and optional RBAC.",
|
|
155
|
+
epilog=EXAMPLES,
|
|
156
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
157
|
+
)
|
|
158
|
+
create.add_argument(
|
|
159
|
+
"name",
|
|
160
|
+
help='Project display name; for example, "Inventory API".',
|
|
161
|
+
)
|
|
162
|
+
create.add_argument(
|
|
163
|
+
"--output-dir",
|
|
164
|
+
type=Path,
|
|
165
|
+
default=Path.cwd(),
|
|
166
|
+
help="Parent directory for the generated project (default: current directory).",
|
|
167
|
+
)
|
|
168
|
+
choice = create.add_mutually_exclusive_group()
|
|
169
|
+
choice.add_argument(
|
|
170
|
+
"--with-rbac",
|
|
171
|
+
action="store_true",
|
|
172
|
+
help="Include roles, permissions, and RBAC administration APIs.",
|
|
173
|
+
)
|
|
174
|
+
choice.add_argument(
|
|
175
|
+
"--without-rbac",
|
|
176
|
+
action="store_true",
|
|
177
|
+
help="Use simpler superuser-based administration without RBAC.",
|
|
178
|
+
)
|
|
179
|
+
create.add_argument(
|
|
180
|
+
"--force",
|
|
181
|
+
action="store_true",
|
|
182
|
+
help="Replace the matching destination after generating a safe backup.",
|
|
183
|
+
)
|
|
184
|
+
commands.add_parser(
|
|
185
|
+
"examples",
|
|
186
|
+
help="Show common FastAPI Forge commands.",
|
|
187
|
+
description="Show copy-ready examples for generating projects.",
|
|
188
|
+
)
|
|
189
|
+
return parser
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def main(argv: Iterable[str] | None = None) -> int:
|
|
193
|
+
parser = build_parser()
|
|
194
|
+
args = parser.parse_args(argv)
|
|
195
|
+
|
|
196
|
+
if args.command == "examples":
|
|
197
|
+
print(EXAMPLES)
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
destination = create_project(
|
|
202
|
+
name=args.name,
|
|
203
|
+
output_dir=args.output_dir,
|
|
204
|
+
with_rbac=resolve_rbac(args),
|
|
205
|
+
force=args.force,
|
|
206
|
+
)
|
|
207
|
+
except ForgeError as exc:
|
|
208
|
+
parser.error(str(exc))
|
|
209
|
+
|
|
210
|
+
print(f"Created project: {destination}")
|
|
211
|
+
return 0
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
FROM python:3.12-slim AS builder
|
|
2
|
+
|
|
3
|
+
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
|
4
|
+
PIP_NO_CACHE_DIR=1 \
|
|
5
|
+
PYTHONDONTWRITEBYTECODE=1
|
|
6
|
+
|
|
7
|
+
WORKDIR /build
|
|
8
|
+
COPY requirements.txt .
|
|
9
|
+
RUN python -m venv /opt/venv && /opt/venv/bin/pip install --upgrade pip && \
|
|
10
|
+
/opt/venv/bin/pip install -r requirements.txt
|
|
11
|
+
|
|
12
|
+
FROM python:3.12-slim AS runtime
|
|
13
|
+
|
|
14
|
+
ENV PATH="/opt/venv/bin:$PATH" \
|
|
15
|
+
PYTHONUNBUFFERED=1 \
|
|
16
|
+
PYTHONDONTWRITEBYTECODE=1
|
|
17
|
+
|
|
18
|
+
RUN groupadd --system app && useradd --system --gid app --create-home app
|
|
19
|
+
WORKDIR /app
|
|
20
|
+
COPY --from=builder /opt/venv /opt/venv
|
|
21
|
+
COPY --chown=app:app alembic alembic
|
|
22
|
+
COPY --chown=app:app alembic.ini .
|
|
23
|
+
COPY --chown=app:app app app
|
|
24
|
+
COPY --chown=app:app scripts scripts
|
|
25
|
+
|
|
26
|
+
USER app
|
|
27
|
+
EXPOSE 8000
|
|
28
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
|
29
|
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health/live', timeout=3)"
|
|
30
|
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2", "--proxy-headers"]
|
|
31
|
+
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# __PROJECT_NAME__
|
|
2
|
+
|
|
3
|
+
A production-oriented FastAPI service with JWT authentication, PostgreSQL, Alembic, persistent audit logs, and role-based access control.
|
|
4
|
+
|
|
5
|
+
## Included
|
|
6
|
+
|
|
7
|
+
- Access tokens plus cookie-only rotating refresh tokens, revocation, and sessions
|
|
8
|
+
- Password reset and email verification
|
|
9
|
+
- Hierarchical roles, permissions, and per-user permissions
|
|
10
|
+
- Structured logs, request IDs, CORS, trusted hosts, rate limiting, and security headers
|
|
11
|
+
- Sync and async SQLAlchemy sessions with configurable pools
|
|
12
|
+
- Liveness, readiness, and database health endpoints
|
|
13
|
+
- Alembic migrations and an idempotent RBAC seed command
|
|
14
|
+
- A non-root Docker image, local Compose stack, tests, Ruff, and CI
|
|
15
|
+
|
|
16
|
+
## Requirements
|
|
17
|
+
|
|
18
|
+
Python 3.12+, PostgreSQL 14+, and optionally Docker with Compose v2.
|
|
19
|
+
|
|
20
|
+
## Local setup
|
|
21
|
+
|
|
22
|
+
Create the PostgreSQL database configured by `.env`, then:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
python3.12 -m venv .venv
|
|
26
|
+
source .venv/bin/activate
|
|
27
|
+
python -m pip install --upgrade pip
|
|
28
|
+
pip install -r requirements-dev.txt
|
|
29
|
+
cp sample.env .env
|
|
30
|
+
alembic upgrade head
|
|
31
|
+
python scripts/seed_first_user.py \
|
|
32
|
+
--email admin@example.com \
|
|
33
|
+
--password 'ChangeMe123!' \
|
|
34
|
+
--full-name 'System Administrator'
|
|
35
|
+
uvicorn app.main:app --reload
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
On Windows PowerShell, activate with `.venv\\Scripts\\Activate.ps1`. Development API documentation is at `http://localhost:8000/docs`.
|
|
39
|
+
|
|
40
|
+
## Docker development
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
cp sample.env .env
|
|
44
|
+
docker compose up --build
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Seed the administrator from another terminal:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
docker compose exec api python scripts/seed_first_user.py \
|
|
51
|
+
--email admin@example.com \
|
|
52
|
+
--password 'ChangeMe123!' \
|
|
53
|
+
--full-name 'System Administrator'
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The Compose stack is for development; its credentials and bind mount are not production settings.
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
| Setting | Purpose |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| `APP_ENV` | Use `development`, `test`, or `production` |
|
|
63
|
+
| `SECRET_KEY` | JWT signing key; production requires a unique 32+ character value |
|
|
64
|
+
| `PG_*` | PostgreSQL connection and TLS mode |
|
|
65
|
+
| `ALLOWED_HOSTS` | Comma-separated production hostnames |
|
|
66
|
+
| `ALLOWED_ORIGINS` | Comma-separated browser origins |
|
|
67
|
+
| `USE_REDIS`, `REDIS_URL` | Shared rate limits across replicas |
|
|
68
|
+
| `LOG_TO_FILES`, `LOG_DIR` | Optional rotating files; stdout stays enabled |
|
|
69
|
+
| `MAIL_*`, `FRONTEND_URL` | Email delivery and frontend links |
|
|
70
|
+
|
|
71
|
+
Production startup rejects default/short secrets, wildcard hosts/origins, and non-TLS PostgreSQL modes.
|
|
72
|
+
|
|
73
|
+
## RBAC
|
|
74
|
+
|
|
75
|
+
Roles contain permissions and may have a parent; users can also receive permissions directly. Codes follow `resource:action`:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
@router.get(
|
|
79
|
+
"/reports",
|
|
80
|
+
dependencies=[Depends(require_permissions("reports:read"))],
|
|
81
|
+
)
|
|
82
|
+
async def list_reports():
|
|
83
|
+
...
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`require_any_permission(...)` accepts any listed permission. Superusers and tokens containing `*` bypass individual checks. The seed command creates `SUPER ADMIN`, `ADMIN`, and `USER`.
|
|
87
|
+
|
|
88
|
+
## Endpoints
|
|
89
|
+
|
|
90
|
+
- `/api/v1/auth`, `/users`, `/roles`, `/permissions`, and `/audit-logs`
|
|
91
|
+
- `/api/v1/health/live`: process liveness
|
|
92
|
+
- `/api/v1/health/ready`: database readiness; returns `503` when unavailable
|
|
93
|
+
|
|
94
|
+
Docs and OpenAPI are disabled in production.
|
|
95
|
+
|
|
96
|
+
The refresh token is stored only in a scoped HTTP-only cookie. Call
|
|
97
|
+
`POST /api/v1/auth/refresh` with credentials enabled; it returns a new access token
|
|
98
|
+
and rotates the refresh cookie without exposing that token to browser JavaScript.
|
|
99
|
+
|
|
100
|
+
## Tests
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
pytest
|
|
104
|
+
ruff check .
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Production checklist
|
|
108
|
+
|
|
109
|
+
1. Build an immutable image: `docker build -t project-api:1.0.0 .`
|
|
110
|
+
2. Supply secrets externally; configure explicit HTTPS origins and hosts.
|
|
111
|
+
3. Use managed PostgreSQL with verified TLS and Redis for multi-replica rate limits.
|
|
112
|
+
4. Run `alembic upgrade head` once as a release job before new replicas.
|
|
113
|
+
5. Terminate TLS at a trusted proxy and centralize stdout/stderr logs.
|
|
114
|
+
6. Monitor liveness/readiness; test backups, restores, rotation, and rollback.
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
docker run --rm --env-file .env project-api:1.0.0 alembic upgrade head
|
|
118
|
+
docker run --rm --env-file .env -p 8000:8000 project-api:1.0.0
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Do not run migrations concurrently from every application replica.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
cache: pip
|
|
19
|
+
- run: python -m pip install --upgrade pip
|
|
20
|
+
- run: pip install -r requirements-dev.txt
|
|
21
|
+
- run: ruff check .
|
|
22
|
+
- run: pytest
|
|
23
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Generate migrations after model changes with: alembic revision --autogenerate -m "describe change"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from logging.config import fileConfig
|
|
2
|
+
|
|
3
|
+
from alembic import context
|
|
4
|
+
from app.db.base import Base
|
|
5
|
+
|
|
6
|
+
from app.core.config import settings
|
|
7
|
+
from app.db.models import *
|
|
8
|
+
|
|
9
|
+
config = context.config
|
|
10
|
+
config.set_main_option("sqlalchemy.url", settings.postgres_url)
|
|
11
|
+
|
|
12
|
+
if config.config_file_name:
|
|
13
|
+
fileConfig(config.config_file_name)
|
|
14
|
+
|
|
15
|
+
target_metadata = Base.metadata
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_migrations_offline():
|
|
19
|
+
context.configure(
|
|
20
|
+
url=settings.postgres_url,
|
|
21
|
+
target_metadata=target_metadata,
|
|
22
|
+
include_schemas=True,
|
|
23
|
+
literal_binds=True,
|
|
24
|
+
dialect_opts={"paramstyle": "named"},
|
|
25
|
+
)
|
|
26
|
+
with context.begin_transaction():
|
|
27
|
+
context.run_migrations()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_migrations_online():
|
|
31
|
+
from sqlalchemy import engine_from_config, pool
|
|
32
|
+
|
|
33
|
+
connectable = engine_from_config(
|
|
34
|
+
configuration=config.get_section(config.config_ini_section),
|
|
35
|
+
prefix="sqlalchemy.",
|
|
36
|
+
poolclass=pool.NullPool,
|
|
37
|
+
)
|
|
38
|
+
with connectable.connect() as connection:
|
|
39
|
+
context.configure(
|
|
40
|
+
connection=connection,
|
|
41
|
+
target_metadata=target_metadata,
|
|
42
|
+
include_schemas=True,
|
|
43
|
+
)
|
|
44
|
+
with context.begin_transaction():
|
|
45
|
+
context.run_migrations()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if context.is_offline_mode():
|
|
49
|
+
run_migrations_offline()
|
|
50
|
+
else:
|
|
51
|
+
run_migrations_online()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision ID: ${up_revision}
|
|
4
|
+
Revises: ${down_revision | comma,n}
|
|
5
|
+
Create Date: ${create_date}
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from typing import Sequence, Union
|
|
9
|
+
|
|
10
|
+
from alembic import op
|
|
11
|
+
import sqlalchemy as sa
|
|
12
|
+
${imports if imports else ""}
|
|
13
|
+
|
|
14
|
+
# revision identifiers, used by Alembic.
|
|
15
|
+
revision: str = ${repr(up_revision)}
|
|
16
|
+
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
|
17
|
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
|
18
|
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def upgrade() -> None:
|
|
22
|
+
"""Upgrade schema."""
|
|
23
|
+
${upgrades if upgrades else "pass"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def downgrade() -> None:
|
|
27
|
+
"""Downgrade schema."""
|
|
28
|
+
${downgrades if downgrades else "pass"}
|