fastapi-spawn 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_spawn/__init__.py +6 -0
- fastapi_spawn/cli.py +387 -0
- fastapi_spawn/config.py +162 -0
- fastapi_spawn/constants.py +133 -0
- fastapi_spawn/generator.py +294 -0
- fastapi_spawn/interactive.py +192 -0
- fastapi_spawn/templates/alembic/alembic.ini.j2 +39 -0
- fastapi_spawn/templates/alembic/env.py.j2 +64 -0
- fastapi_spawn/templates/app/__init__.py.j2 +1 -0
- fastapi_spawn/templates/app/api/deps.py.j2 +39 -0
- fastapi_spawn/templates/app/api/v1/auth.py.j2 +59 -0
- fastapi_spawn/templates/app/api/v1/health.py.j2 +40 -0
- fastapi_spawn/templates/app/core/ai.py.j2 +76 -0
- fastapi_spawn/templates/app/core/config.py.j2 +177 -0
- fastapi_spawn/templates/app/core/exceptions.py.j2 +43 -0
- fastapi_spawn/templates/app/core/logging.py.j2 +70 -0
- fastapi_spawn/templates/app/core/security.py.j2 +42 -0
- fastapi_spawn/templates/app/core/storage.py.j2 +73 -0
- fastapi_spawn/templates/app/db/session.py.j2 +84 -0
- fastapi_spawn/templates/app/main.py.j2 +71 -0
- fastapi_spawn/templates/base/Makefile.j2 +45 -0
- fastapi_spawn/templates/base/README.md.j2 +74 -0
- fastapi_spawn/templates/base/env.j2 +82 -0
- fastapi_spawn/templates/base/env_example.j2 +85 -0
- fastapi_spawn/templates/base/gitignore.j2 +38 -0
- fastapi_spawn/templates/base/pre_commit.j2 +17 -0
- fastapi_spawn/templates/base/pyproject.toml.j2 +129 -0
- fastapi_spawn/templates/ci/github/publish.yml.j2 +32 -0
- fastapi_spawn/templates/ci/github/tests.yml.j2 +39 -0
- fastapi_spawn/templates/ci/gitlab/gitlab-ci.yml.j2 +29 -0
- fastapi_spawn/templates/docker/Dockerfile.j2 +17 -0
- fastapi_spawn/templates/docker/docker-compose.yml.j2 +97 -0
- fastapi_spawn/templates/docker/dockerignore.j2 +13 -0
- fastapi_spawn/templates/infra/docker/docker-compose.prod.yml.j2 +43 -0
- fastapi_spawn/templates/infra/helm/Chart.yaml.j2 +6 -0
- fastapi_spawn/templates/infra/helm/values.yaml.j2 +26 -0
- fastapi_spawn/templates/infra/terraform/main.tf.j2 +26 -0
- fastapi_spawn/templates/infra/terraform/variables.tf.j2 +17 -0
- fastapi_spawn/templates/root/main.py.j2 +16 -0
- fastapi_spawn/templates/tasks/celery_app.py.j2 +37 -0
- fastapi_spawn/templates/tasks/sample_tasks.py.j2 +27 -0
- fastapi_spawn/templates/tests/conftest.py.j2 +22 -0
- fastapi_spawn/templates/tests/test_health.py.j2 +30 -0
- fastapi_spawn/utils.py +58 -0
- fastapi_spawn/validators.py +67 -0
- fastapi_spawn-0.1.0.dist-info/METADATA +262 -0
- fastapi_spawn-0.1.0.dist-info/RECORD +50 -0
- fastapi_spawn-0.1.0.dist-info/WHEEL +4 -0
- fastapi_spawn-0.1.0.dist-info/entry_points.txt +2 -0
- fastapi_spawn-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Health check endpoints — /health, /readiness, /liveness."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/health")
|
|
11
|
+
|
|
12
|
+
_start_time = time.time()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HealthResponse(BaseModel):
|
|
16
|
+
status: str
|
|
17
|
+
uptime_seconds: float
|
|
18
|
+
version: str = "0.1.0"
|
|
19
|
+
service: str = "{{ project_name }}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.get("", response_model=HealthResponse, summary="Basic health check")
|
|
23
|
+
async def health() -> HealthResponse:
|
|
24
|
+
"""Returns service status and uptime."""
|
|
25
|
+
return HealthResponse(
|
|
26
|
+
status="ok",
|
|
27
|
+
uptime_seconds=round(time.time() - _start_time, 2),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.get("/readiness", summary="Readiness probe (Kubernetes)")
|
|
32
|
+
async def readiness() -> dict:
|
|
33
|
+
"""Indicates whether the service is ready to accept traffic."""
|
|
34
|
+
return {"status": "ready"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.get("/liveness", summary="Liveness probe (Kubernetes)")
|
|
38
|
+
async def liveness() -> dict:
|
|
39
|
+
"""Indicates whether the service process is alive."""
|
|
40
|
+
return {"status": "alive"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""AI client utilities for {{ project_name }}."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
|
|
7
|
+
{% if ai == "openai" %}
|
|
8
|
+
from openai import AsyncOpenAI
|
|
9
|
+
|
|
10
|
+
from app.core.config import settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@lru_cache
|
|
14
|
+
def get_openai_client() -> AsyncOpenAI:
|
|
15
|
+
"""Return a cached AsyncOpenAI client."""
|
|
16
|
+
return AsyncOpenAI(
|
|
17
|
+
api_key=settings.OPENAI_API_KEY,
|
|
18
|
+
base_url=settings.OPENAI_BASE_URL or None, # None = default api.openai.com
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def chat_completion(
|
|
23
|
+
messages: list[dict],
|
|
24
|
+
model: str | None = None,
|
|
25
|
+
temperature: float = 0.7,
|
|
26
|
+
max_tokens: int = 1024,
|
|
27
|
+
) -> str:
|
|
28
|
+
"""Send a chat completion request and return the response text."""
|
|
29
|
+
client = get_openai_client()
|
|
30
|
+
response = await client.chat.completions.create(
|
|
31
|
+
model=model or settings.OPENAI_MODEL,
|
|
32
|
+
messages=messages,
|
|
33
|
+
temperature=temperature,
|
|
34
|
+
max_tokens=max_tokens,
|
|
35
|
+
)
|
|
36
|
+
return response.choices[0].message.content or ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def get_embedding(text: str, model: str | None = None) -> list[float]:
|
|
40
|
+
"""Return an embedding vector for the given text."""
|
|
41
|
+
client = get_openai_client()
|
|
42
|
+
response = await client.embeddings.create(
|
|
43
|
+
model=model or settings.OPENAI_EMBEDDING_MODEL,
|
|
44
|
+
input=text,
|
|
45
|
+
)
|
|
46
|
+
return response.data[0].embedding
|
|
47
|
+
|
|
48
|
+
{% elif ai == "anthropic" %}
|
|
49
|
+
import anthropic
|
|
50
|
+
|
|
51
|
+
from app.core.config import settings
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@lru_cache
|
|
55
|
+
def get_anthropic_client() -> anthropic.AsyncAnthropic:
|
|
56
|
+
"""Return a cached AsyncAnthropic client."""
|
|
57
|
+
return anthropic.AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def chat_completion(
|
|
61
|
+
messages: list[dict],
|
|
62
|
+
system: str = "You are a helpful assistant.",
|
|
63
|
+
model: str | None = None,
|
|
64
|
+
max_tokens: int = 1024,
|
|
65
|
+
) -> str:
|
|
66
|
+
"""Send a message to Claude and return the response text."""
|
|
67
|
+
client = get_anthropic_client()
|
|
68
|
+
response = await client.messages.create(
|
|
69
|
+
model=model or settings.ANTHROPIC_MODEL,
|
|
70
|
+
max_tokens=max_tokens,
|
|
71
|
+
system=system,
|
|
72
|
+
messages=messages,
|
|
73
|
+
)
|
|
74
|
+
return response.content[0].text
|
|
75
|
+
|
|
76
|
+
{% endif %}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Application configuration using Pydantic Settings v2."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import field_validator
|
|
9
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Settings(BaseSettings):
|
|
13
|
+
model_config = SettingsConfigDict(
|
|
14
|
+
env_file=".env",
|
|
15
|
+
env_file_encoding="utf-8",
|
|
16
|
+
case_sensitive=False,
|
|
17
|
+
extra="ignore",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# ── App ────────────────────────────────────────────────────────────────
|
|
21
|
+
APP_NAME: str = "{{ project_name }}"
|
|
22
|
+
APP_ENV: str = "development"
|
|
23
|
+
DEBUG: bool = False
|
|
24
|
+
API_V1_PREFIX: str = "/api/v1"
|
|
25
|
+
SECRET_KEY: str = "change-me"
|
|
26
|
+
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:8000"]
|
|
27
|
+
|
|
28
|
+
@field_validator("CORS_ORIGINS", mode="before")
|
|
29
|
+
@classmethod
|
|
30
|
+
def assemble_cors_origins(cls, v: Any) -> list[str]:
|
|
31
|
+
if isinstance(v, str):
|
|
32
|
+
return [o.strip() for o in v.split(",")]
|
|
33
|
+
return v
|
|
34
|
+
|
|
35
|
+
{% if db == "postgresql" %}
|
|
36
|
+
# ── PostgreSQL ─────────────────────────────────────────────────────────
|
|
37
|
+
POSTGRES_USER: str = "postgres"
|
|
38
|
+
POSTGRES_PASSWORD: str = "postgres"
|
|
39
|
+
POSTGRES_HOST: str = "localhost"
|
|
40
|
+
POSTGRES_PORT: str = "5432"
|
|
41
|
+
POSTGRES_DB: str = "{{ slug }}_db"
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def database_url(self) -> str:
|
|
45
|
+
return (
|
|
46
|
+
f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
|
|
47
|
+
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
{% elif db == "mysql" %}
|
|
51
|
+
# ── MySQL ──────────────────────────────────────────────────────────────
|
|
52
|
+
MYSQL_USER: str = "root"
|
|
53
|
+
MYSQL_PASSWORD: str = "mysql"
|
|
54
|
+
MYSQL_HOST: str = "localhost"
|
|
55
|
+
MYSQL_PORT: str = "3306"
|
|
56
|
+
MYSQL_DB: str = "{{ slug }}_db"
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def database_url(self) -> str:
|
|
60
|
+
return (
|
|
61
|
+
f"mysql+aiomysql://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}"
|
|
62
|
+
f"@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DB}"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
{% elif db == "sqlite" %}
|
|
66
|
+
# ── SQLite ─────────────────────────────────────────────────────────────
|
|
67
|
+
SQLITE_FILE: str = "{{ slug }}.db"
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def database_url(self) -> str:
|
|
71
|
+
return f"sqlite+aiosqlite:///./{self.SQLITE_FILE}"
|
|
72
|
+
|
|
73
|
+
{% endif %}
|
|
74
|
+
{% if has_mongo %}
|
|
75
|
+
# ── MongoDB ────────────────────────────────────────────────────────────
|
|
76
|
+
MONGODB_USER: str = "mongo"
|
|
77
|
+
MONGODB_PASSWORD: str = "mongo"
|
|
78
|
+
MONGODB_HOST: str = "localhost"
|
|
79
|
+
MONGODB_PORT: str = "27017"
|
|
80
|
+
MONGODB_DB: str = "{{ slug }}_db"
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def mongodb_url(self) -> str:
|
|
84
|
+
if self.MONGODB_USER and self.MONGODB_PASSWORD:
|
|
85
|
+
return (
|
|
86
|
+
f"mongodb://{self.MONGODB_USER}:{self.MONGODB_PASSWORD}"
|
|
87
|
+
f"@{self.MONGODB_HOST}:{self.MONGODB_PORT}"
|
|
88
|
+
)
|
|
89
|
+
return f"mongodb://{self.MONGODB_HOST}:{self.MONGODB_PORT}"
|
|
90
|
+
|
|
91
|
+
{% endif %}
|
|
92
|
+
{% if broker == "redis" or cache == "redis" %}
|
|
93
|
+
# ── Redis ──────────────────────────────────────────────────────────────
|
|
94
|
+
REDIS_HOST: str = "localhost"
|
|
95
|
+
REDIS_PORT: str = "6379"
|
|
96
|
+
REDIS_PASSWORD: str = ""
|
|
97
|
+
REDIS_DB: str = "0"
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def redis_url(self) -> str:
|
|
101
|
+
if self.REDIS_PASSWORD:
|
|
102
|
+
return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
|
103
|
+
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
|
104
|
+
|
|
105
|
+
{% endif %}
|
|
106
|
+
{% if broker == "rabbitmq" %}
|
|
107
|
+
# ── RabbitMQ ───────────────────────────────────────────────────────────
|
|
108
|
+
RABBITMQ_USER: str = "guest"
|
|
109
|
+
RABBITMQ_PASSWORD: str = "guest"
|
|
110
|
+
RABBITMQ_HOST: str = "localhost"
|
|
111
|
+
RABBITMQ_PORT: str = "5672"
|
|
112
|
+
RABBITMQ_VHOST: str = "/"
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def rabbitmq_url(self) -> str:
|
|
116
|
+
return (
|
|
117
|
+
f"amqp://{self.RABBITMQ_USER}:{self.RABBITMQ_PASSWORD}"
|
|
118
|
+
f"@{self.RABBITMQ_HOST}:{self.RABBITMQ_PORT}/{self.RABBITMQ_VHOST}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
{% endif %}
|
|
122
|
+
{% if broker == "kafka" %}
|
|
123
|
+
# ── Kafka ──────────────────────────────────────────────────────────────
|
|
124
|
+
KAFKA_HOST: str = "localhost"
|
|
125
|
+
KAFKA_PORT: str = "9092"
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def kafka_bootstrap_servers(self) -> str:
|
|
129
|
+
return f"{self.KAFKA_HOST}:{self.KAFKA_PORT}"
|
|
130
|
+
|
|
131
|
+
{% endif %}
|
|
132
|
+
{% if has_s3 %}
|
|
133
|
+
# ── AWS S3 ─────────────────────────────────────────────────────────────
|
|
134
|
+
AWS_ACCESS_KEY_ID: str = ""
|
|
135
|
+
AWS_SECRET_ACCESS_KEY: str = ""
|
|
136
|
+
AWS_REGION: str = "us-east-1"
|
|
137
|
+
AWS_S3_BUCKET: str = "{{ slug }}-bucket"
|
|
138
|
+
AWS_S3_ENDPOINT_URL: str = "" # Override for MinIO / LocalStack
|
|
139
|
+
|
|
140
|
+
{% endif %}
|
|
141
|
+
{% if has_auth %}
|
|
142
|
+
# ── Auth ───────────────────────────────────────────────────────────────
|
|
143
|
+
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
|
144
|
+
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
|
145
|
+
ALGORITHM: str = "HS256"
|
|
146
|
+
|
|
147
|
+
{% endif %}
|
|
148
|
+
{% if ai == "openai" %}
|
|
149
|
+
# ── OpenAI ─────────────────────────────────────────────────────────────
|
|
150
|
+
OPENAI_API_KEY: str = ""
|
|
151
|
+
OPENAI_MODEL: str = "gpt-4o"
|
|
152
|
+
OPENAI_EMBEDDING_MODEL: str = "text-embedding-3-small"
|
|
153
|
+
OPENAI_BASE_URL: str = "" # Override for Azure OpenAI, LM Studio, local endpoints
|
|
154
|
+
|
|
155
|
+
{% elif ai == "anthropic" %}
|
|
156
|
+
# ── Anthropic ──────────────────────────────────────────────────────────
|
|
157
|
+
ANTHROPIC_API_KEY: str = ""
|
|
158
|
+
ANTHROPIC_MODEL: str = "claude-3-5-sonnet-20241022"
|
|
159
|
+
|
|
160
|
+
{% endif %}
|
|
161
|
+
|
|
162
|
+
# ── Helpers ────────────────────────────────────────────────────────────
|
|
163
|
+
@property
|
|
164
|
+
def is_development(self) -> bool:
|
|
165
|
+
return self.APP_ENV == "development"
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def is_production(self) -> bool:
|
|
169
|
+
return self.APP_ENV == "production"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@lru_cache
|
|
173
|
+
def get_settings() -> Settings:
|
|
174
|
+
return Settings()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
settings: Settings = get_settings()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Global exception handlers."""
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI, Request, status
|
|
4
|
+
from fastapi.responses import JSONResponse
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AppError(Exception):
|
|
8
|
+
"""Base application error."""
|
|
9
|
+
def __init__(self, message: str, status_code: int = 400) -> None:
|
|
10
|
+
self.message = message
|
|
11
|
+
self.status_code = status_code
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NotFoundError(AppError):
|
|
16
|
+
def __init__(self, resource: str = "Resource") -> None:
|
|
17
|
+
super().__init__(f"{resource} not found.", status_code=404)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UnauthorizedError(AppError):
|
|
21
|
+
def __init__(self, detail: str = "Not authenticated.") -> None:
|
|
22
|
+
super().__init__(detail, status_code=401)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ForbiddenError(AppError):
|
|
26
|
+
def __init__(self, detail: str = "Permission denied.") -> None:
|
|
27
|
+
super().__init__(detail, status_code=403)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def register_exception_handlers(app: FastAPI) -> None:
|
|
31
|
+
@app.exception_handler(AppError)
|
|
32
|
+
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
|
|
33
|
+
return JSONResponse(
|
|
34
|
+
status_code=exc.status_code,
|
|
35
|
+
content={"detail": exc.message, "type": type(exc).__name__},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
@app.exception_handler(Exception)
|
|
39
|
+
async def unhandled_error_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
40
|
+
return JSONResponse(
|
|
41
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
42
|
+
content={"detail": "An unexpected error occurred.", "type": "InternalServerError"},
|
|
43
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Structured logging configuration."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
{% if log_lib == "loguru" %}
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def configure_logging() -> None:
|
|
11
|
+
"""Configure loguru as the application logger."""
|
|
12
|
+
logger.remove()
|
|
13
|
+
logger.add(
|
|
14
|
+
sys.stdout,
|
|
15
|
+
colorize=True,
|
|
16
|
+
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
|
|
17
|
+
level="DEBUG",
|
|
18
|
+
backtrace=True,
|
|
19
|
+
diagnose=True,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Intercept standard library logging
|
|
23
|
+
class InterceptHandler(logging.Handler):
|
|
24
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
25
|
+
try:
|
|
26
|
+
level = logger.level(record.levelname).name
|
|
27
|
+
except ValueError:
|
|
28
|
+
level = record.levelno # type: ignore[assignment]
|
|
29
|
+
frame, depth = sys._getframe(6), 6
|
|
30
|
+
while frame and frame.f_code.co_filename == logging.__file__:
|
|
31
|
+
frame = frame.f_back # type: ignore[assignment]
|
|
32
|
+
depth += 1
|
|
33
|
+
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
|
|
34
|
+
|
|
35
|
+
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
|
|
36
|
+
|
|
37
|
+
{% elif log_lib == "structlog" %}
|
|
38
|
+
import structlog
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def configure_logging() -> None:
|
|
42
|
+
"""Configure structlog as the application logger."""
|
|
43
|
+
structlog.configure(
|
|
44
|
+
processors=[
|
|
45
|
+
structlog.contextvars.merge_contextvars,
|
|
46
|
+
structlog.stdlib.filter_by_level,
|
|
47
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
48
|
+
structlog.stdlib.add_logger_name,
|
|
49
|
+
structlog.stdlib.add_log_level,
|
|
50
|
+
structlog.stdlib.PositionalArgumentsFormatter(),
|
|
51
|
+
structlog.processors.StackInfoRenderer(),
|
|
52
|
+
structlog.processors.format_exc_info,
|
|
53
|
+
structlog.dev.ConsoleRenderer(),
|
|
54
|
+
],
|
|
55
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
56
|
+
context_class=dict,
|
|
57
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
58
|
+
cache_logger_on_first_use=True,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
{% else %}
|
|
62
|
+
def configure_logging() -> None:
|
|
63
|
+
"""Configure standard library logging."""
|
|
64
|
+
logging.basicConfig(
|
|
65
|
+
level=logging.INFO,
|
|
66
|
+
format="%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d - %(message)s",
|
|
67
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
68
|
+
stream=sys.stdout,
|
|
69
|
+
)
|
|
70
|
+
{% endif %}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Security utilities — JWT token creation and verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from jose import JWTError, jwt
|
|
9
|
+
from passlib.context import CryptContext
|
|
10
|
+
|
|
11
|
+
from app.core.config import settings
|
|
12
|
+
|
|
13
|
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
17
|
+
return pwd_context.verify(plain_password, hashed_password)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_password_hash(password: str) -> str:
|
|
21
|
+
return pwd_context.hash(password)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_access_token(subject: str | Any, expires_delta: timedelta | None = None) -> str:
|
|
25
|
+
expire = datetime.now(timezone.utc) + (
|
|
26
|
+
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
27
|
+
)
|
|
28
|
+
payload = {"sub": str(subject), "exp": expire, "type": "access"}
|
|
29
|
+
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_refresh_token(subject: str | Any) -> str:
|
|
33
|
+
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
|
34
|
+
payload = {"sub": str(subject), "exp": expire, "type": "refresh"}
|
|
35
|
+
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def decode_token(token: str) -> dict[str, Any]:
|
|
39
|
+
try:
|
|
40
|
+
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
|
41
|
+
except JWTError as exc:
|
|
42
|
+
raise ValueError("Invalid or expired token.") from exc
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""AWS S3 storage utilities for {{ project_name }}."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import boto3
|
|
6
|
+
from botocore.exceptions import ClientError
|
|
7
|
+
|
|
8
|
+
from app.core.config import settings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_client():
|
|
12
|
+
"""Return a configured boto3 S3 client."""
|
|
13
|
+
kwargs = {
|
|
14
|
+
"region_name": settings.AWS_REGION,
|
|
15
|
+
"aws_access_key_id": settings.AWS_ACCESS_KEY_ID,
|
|
16
|
+
"aws_secret_access_key": settings.AWS_SECRET_ACCESS_KEY,
|
|
17
|
+
}
|
|
18
|
+
if settings.AWS_S3_ENDPOINT_URL:
|
|
19
|
+
kwargs["endpoint_url"] = settings.AWS_S3_ENDPOINT_URL # MinIO / LocalStack
|
|
20
|
+
return boto3.client("s3", **kwargs)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def upload_file(file_path: str, object_key: str, bucket: str | None = None) -> str:
|
|
24
|
+
"""
|
|
25
|
+
Upload a local file to S3.
|
|
26
|
+
Returns the public object key on success.
|
|
27
|
+
"""
|
|
28
|
+
bucket = bucket or settings.AWS_S3_BUCKET
|
|
29
|
+
client = _get_client()
|
|
30
|
+
client.upload_file(file_path, bucket, object_key)
|
|
31
|
+
return object_key
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def upload_fileobj(file_obj, object_key: str, bucket: str | None = None, content_type: str = "application/octet-stream") -> str:
|
|
35
|
+
"""Upload a file-like object to S3."""
|
|
36
|
+
bucket = bucket or settings.AWS_S3_BUCKET
|
|
37
|
+
client = _get_client()
|
|
38
|
+
client.upload_fileobj(
|
|
39
|
+
file_obj,
|
|
40
|
+
bucket,
|
|
41
|
+
object_key,
|
|
42
|
+
ExtraArgs={"ContentType": content_type},
|
|
43
|
+
)
|
|
44
|
+
return object_key
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def generate_presigned_url(object_key: str, expires_in: int = 3600, bucket: str | None = None) -> str:
|
|
48
|
+
"""Generate a pre-signed URL for temporary read access."""
|
|
49
|
+
bucket = bucket or settings.AWS_S3_BUCKET
|
|
50
|
+
client = _get_client()
|
|
51
|
+
return client.generate_presigned_url(
|
|
52
|
+
"get_object",
|
|
53
|
+
Params={"Bucket": bucket, "Key": object_key},
|
|
54
|
+
ExpiresIn=expires_in,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def delete_file(object_key: str, bucket: str | None = None) -> None:
|
|
59
|
+
"""Delete an object from S3."""
|
|
60
|
+
bucket = bucket or settings.AWS_S3_BUCKET
|
|
61
|
+
client = _get_client()
|
|
62
|
+
client.delete_object(Bucket=bucket, Key=object_key)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def file_exists(object_key: str, bucket: str | None = None) -> bool:
|
|
66
|
+
"""Check if an object exists in S3."""
|
|
67
|
+
bucket = bucket or settings.AWS_S3_BUCKET
|
|
68
|
+
client = _get_client()
|
|
69
|
+
try:
|
|
70
|
+
client.head_object(Bucket=bucket, Key=object_key)
|
|
71
|
+
return True
|
|
72
|
+
except ClientError:
|
|
73
|
+
return False
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Database session factory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
{% if has_relational_db and orm == "sqlalchemy" %}
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
7
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
8
|
+
|
|
9
|
+
from app.core.config import settings
|
|
10
|
+
|
|
11
|
+
engine = create_async_engine(
|
|
12
|
+
settings.database_url,
|
|
13
|
+
echo=settings.DEBUG,
|
|
14
|
+
pool_pre_ping=True,
|
|
15
|
+
pool_size=10,
|
|
16
|
+
max_overflow=20,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
AsyncSessionLocal = async_sessionmaker(
|
|
20
|
+
bind=engine,
|
|
21
|
+
class_=AsyncSession,
|
|
22
|
+
expire_on_commit=False,
|
|
23
|
+
autoflush=False,
|
|
24
|
+
autocommit=False,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Base(DeclarativeBase):
|
|
29
|
+
"""Base class for all SQLAlchemy ORM models."""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def get_session() -> AsyncSession: # type: ignore[return]
|
|
34
|
+
"""FastAPI dependency that yields an async DB session."""
|
|
35
|
+
async with AsyncSessionLocal() as session:
|
|
36
|
+
try:
|
|
37
|
+
yield session
|
|
38
|
+
await session.commit()
|
|
39
|
+
except Exception:
|
|
40
|
+
await session.rollback()
|
|
41
|
+
raise
|
|
42
|
+
finally:
|
|
43
|
+
await session.close()
|
|
44
|
+
|
|
45
|
+
{% elif has_relational_db and orm == "tortoise" %}
|
|
46
|
+
from tortoise import Tortoise
|
|
47
|
+
|
|
48
|
+
from app.core.config import settings
|
|
49
|
+
|
|
50
|
+
TORTOISE_ORM = {
|
|
51
|
+
"connections": {"default": settings.database_url},
|
|
52
|
+
"apps": {
|
|
53
|
+
"models": {
|
|
54
|
+
"models": ["app.models", "aerich.models"],
|
|
55
|
+
"default_connection": "default",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def init_db() -> None:
|
|
62
|
+
await Tortoise.init(config=TORTOISE_ORM)
|
|
63
|
+
await Tortoise.generate_schemas()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def close_db() -> None:
|
|
67
|
+
await Tortoise.close_connections()
|
|
68
|
+
|
|
69
|
+
{% elif has_mongo and orm == "beanie" %}
|
|
70
|
+
import motor.motor_asyncio
|
|
71
|
+
from beanie import init_beanie
|
|
72
|
+
|
|
73
|
+
from app.core.config import settings
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def init_db() -> None:
|
|
77
|
+
client = motor.motor_asyncio.AsyncIOMotorClient(settings.mongodb_url)
|
|
78
|
+
database = client[settings.MONGODB_DB]
|
|
79
|
+
# TODO: Add your Beanie Document models to the list below
|
|
80
|
+
await init_beanie(database=database, document_models=[])
|
|
81
|
+
|
|
82
|
+
{% else %}
|
|
83
|
+
# No database configured. Add your connection logic here.
|
|
84
|
+
{% endif %}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""FastAPI application entrypoint for {{ project_name }}."""
|
|
2
|
+
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from typing import AsyncGenerator
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
8
|
+
from fastapi.responses import ORJSONResponse
|
|
9
|
+
|
|
10
|
+
from app.api.v1 import health
|
|
11
|
+
{% if has_auth %}
|
|
12
|
+
from app.api.v1 import auth
|
|
13
|
+
{% endif %}
|
|
14
|
+
from app.core.config import settings
|
|
15
|
+
from app.core.exceptions import register_exception_handlers
|
|
16
|
+
from app.core.logging import configure_logging
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@asynccontextmanager
|
|
20
|
+
async def lifespan(application: FastAPI) -> AsyncGenerator[None, None]:
|
|
21
|
+
"""Manage application startup and shutdown lifecycle."""
|
|
22
|
+
configure_logging()
|
|
23
|
+
{% if has_relational_db and orm == "sqlalchemy" %}
|
|
24
|
+
from app.db.session import engine, Base
|
|
25
|
+
async with engine.begin() as conn:
|
|
26
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
27
|
+
{% endif %}
|
|
28
|
+
{% if has_mongo and orm == "beanie" %}
|
|
29
|
+
from app.db.session import init_db
|
|
30
|
+
await init_db()
|
|
31
|
+
{% endif %}
|
|
32
|
+
yield
|
|
33
|
+
{% if has_relational_db and orm == "sqlalchemy" %}
|
|
34
|
+
await engine.dispose()
|
|
35
|
+
{% endif %}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def create_application() -> FastAPI:
|
|
39
|
+
application = FastAPI(
|
|
40
|
+
title=settings.APP_NAME,
|
|
41
|
+
description="Generated by fastapi-spawn 🚀",
|
|
42
|
+
version="0.1.0",
|
|
43
|
+
docs_url="/docs",
|
|
44
|
+
redoc_url="/redoc",
|
|
45
|
+
openapi_url="/openapi.json",
|
|
46
|
+
default_response_class=ORJSONResponse,
|
|
47
|
+
lifespan=lifespan,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Middleware
|
|
51
|
+
application.add_middleware(
|
|
52
|
+
CORSMiddleware,
|
|
53
|
+
allow_origins=settings.CORS_ORIGINS,
|
|
54
|
+
allow_credentials=True,
|
|
55
|
+
allow_methods=["*"],
|
|
56
|
+
allow_headers=["*"],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Exception handlers
|
|
60
|
+
register_exception_handlers(application)
|
|
61
|
+
|
|
62
|
+
# Routers
|
|
63
|
+
application.include_router(health.router, prefix=settings.API_V1_PREFIX, tags=["Health"])
|
|
64
|
+
{% if has_auth %}
|
|
65
|
+
application.include_router(auth.router, prefix=settings.API_V1_PREFIX, tags=["Auth"])
|
|
66
|
+
{% endif %}
|
|
67
|
+
|
|
68
|
+
return application
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
app = create_application()
|