fastforge-cli 0.0.1__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.
- fastforge/__init__.py +1 -0
- fastforge/cli.py +693 -0
- fastforge/infra_template/cookiecutter.json +11 -0
- fastforge/infra_template/hooks/post_gen_project.py +77 -0
- fastforge/infra_template/{{cookiecutter.project_slug}}-infrastructure/README.md +41 -0
- fastforge/infra_template/{{cookiecutter.project_slug}}-infrastructure/docker-compose.app.yml +27 -0
- fastforge/infra_template/{{cookiecutter.project_slug}}-infrastructure/docker-compose.elasticsearch.yml +32 -0
- fastforge/infra_template/{{cookiecutter.project_slug}}-infrastructure/docker-compose.fluentbit.yml +14 -0
- fastforge/infra_template/{{cookiecutter.project_slug}}-infrastructure/docker-compose.kafka.yml +20 -0
- fastforge/infra_template/{{cookiecutter.project_slug}}-infrastructure/docker-compose.logstash.yml +14 -0
- fastforge/infra_template/{{cookiecutter.project_slug}}-infrastructure/docker-compose.mongodb.yml +17 -0
- fastforge/infra_template/{{cookiecutter.project_slug}}-infrastructure/docker-compose.postgres.yml +21 -0
- fastforge/infra_template/{{cookiecutter.project_slug}}-infrastructure/docker-compose.vault.yml +19 -0
- fastforge/infra_template/{{cookiecutter.project_slug}}-infrastructure/docker-compose.vector-agent.yml +13 -0
- fastforge/infra_template/{{cookiecutter.project_slug}}-infrastructure/docker-compose.vector-aggregator.yml +9 -0
- fastforge/infra_template/{{cookiecutter.project_slug}}-infrastructure/docker-compose.yml +31 -0
- fastforge/infra_template/{{cookiecutter.project_slug}}-infrastructure/fluentbit/fluent-bit.conf +24 -0
- fastforge/infra_template/{{cookiecutter.project_slug}}-infrastructure/fluentbit/parsers.conf +5 -0
- fastforge/infra_template/{{cookiecutter.project_slug}}-infrastructure/logstash/pipeline/logstash.conf +31 -0
- fastforge/infra_template/{{cookiecutter.project_slug}}-infrastructure/vault/config.hcl +10 -0
- fastforge/infra_template/{{cookiecutter.project_slug}}-infrastructure/vault/policies/app-policy.hcl +7 -0
- fastforge/infra_template/{{cookiecutter.project_slug}}-infrastructure/vector/vector-agent.toml +36 -0
- fastforge/infra_template/{{cookiecutter.project_slug}}-infrastructure/vector/vector-aggregator.toml +29 -0
- fastforge/template/cookiecutter.json +34 -0
- fastforge/template/hooks/post_gen_project.py +71 -0
- fastforge/template/{{cookiecutter.project_slug}}/.codeclimate.yml +40 -0
- fastforge/template/{{cookiecutter.project_slug}}/.dockerignore +15 -0
- fastforge/template/{{cookiecutter.project_slug}}/.env.staging +86 -0
- fastforge/template/{{cookiecutter.project_slug}}/.gitignore +15 -0
- fastforge/template/{{cookiecutter.project_slug}}/.pre-commit-config.yaml +16 -0
- fastforge/template/{{cookiecutter.project_slug}}/Dockerfile +30 -0
- fastforge/template/{{cookiecutter.project_slug}}/README.md +254 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/__init__.py +0 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/api/__init__.py +0 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/api/exception_handlers.py +78 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/api/models/__init__.py +0 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/api/models/{{cookiecutter.model_name}}.py +22 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/api/routes/__init__.py +0 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/api/routes/health.py +20 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/api/routes/{{cookiecutter.model_name_plural}}.py +70 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/cache.py +63 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/config.py +112 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/db/__init__.py +0 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/db/models/__init__.py +0 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/db/models/{{cookiecutter.model_name}}.py +34 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/db/mongodb.py +23 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/db/sqlalchemy.py +14 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/dependencies.py +45 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/logging_config.py +84 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/main.py +106 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/middleware/__init__.py +0 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/middleware/logging_middleware.py +45 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/middleware/security_headers.py +18 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/repositories/__init__.py +0 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/repositories/{{cookiecutter.model_name}}_repository.py +172 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/secrets.py +101 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/services/__init__.py +0 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/services/{{cookiecutter.model_name}}_service.py +63 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/streaming/__init__.py +0 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/streaming/consumer.py +187 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/streaming/handler.py +31 -0
- fastforge/template/{{cookiecutter.project_slug}}/app/streaming/producer.py +151 -0
- fastforge/template/{{cookiecutter.project_slug}}/docker-compose.debug.yml +15 -0
- fastforge/template/{{cookiecutter.project_slug}}/docker-compose.yml +141 -0
- fastforge/template/{{cookiecutter.project_slug}}/pyproject.toml +100 -0
- fastforge/template/{{cookiecutter.project_slug}}/qodana.yaml +22 -0
- fastforge/template/{{cookiecutter.project_slug}}/sonar-project.properties +20 -0
- fastforge/template/{{cookiecutter.project_slug}}/tests/__init__.py +0 -0
- fastforge/template/{{cookiecutter.project_slug}}/tests/conftest.py +11 -0
- fastforge/template/{{cookiecutter.project_slug}}/tests/test_api.py +51 -0
- fastforge_cli-0.0.1.dist-info/METADATA +163 -0
- fastforge_cli-0.0.1.dist-info/RECORD +76 -0
- fastforge_cli-0.0.1.dist-info/WHEEL +5 -0
- fastforge_cli-0.0.1.dist-info/entry_points.txt +12 -0
- fastforge_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
- fastforge_cli-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# {{ cookiecutter.project_name }} — Staging
|
|
2
|
+
APP_NAME={{ cookiecutter.project_slug }}
|
|
3
|
+
APP_ENV=staging
|
|
4
|
+
APP_DEBUG=true
|
|
5
|
+
APP_PORT={{ cookiecutter.port }}
|
|
6
|
+
APP_LOG_LEVEL=debug
|
|
7
|
+
{%- if cookiecutter.logging == "structlog" %}
|
|
8
|
+
|
|
9
|
+
# Logging
|
|
10
|
+
LOG_FORMAT={{ cookiecutter.log_format }}
|
|
11
|
+
LOG_FILE_ENABLED=false
|
|
12
|
+
{%- endif %}
|
|
13
|
+
{%- if cookiecutter.database == "postgres" %}
|
|
14
|
+
|
|
15
|
+
# Database
|
|
16
|
+
DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/{{ cookiecutter.package_name }}
|
|
17
|
+
{%- endif %}
|
|
18
|
+
{%- if cookiecutter.database == "mysql" %}
|
|
19
|
+
|
|
20
|
+
# Database
|
|
21
|
+
DATABASE_URL=mysql+aiomysql://root:root@mysql:3306/{{ cookiecutter.package_name }}
|
|
22
|
+
{%- endif %}
|
|
23
|
+
{%- if cookiecutter.database == "sqlite" %}
|
|
24
|
+
|
|
25
|
+
# Database
|
|
26
|
+
DATABASE_URL=sqlite+aiosqlite:///./{{ cookiecutter.package_name }}.db
|
|
27
|
+
{%- endif %}
|
|
28
|
+
{%- if cookiecutter.database == "mongodb" %}
|
|
29
|
+
|
|
30
|
+
# Database
|
|
31
|
+
MONGODB_URL=mongodb://mongodb:27017
|
|
32
|
+
MONGODB_DATABASE={{ cookiecutter.package_name }}
|
|
33
|
+
{%- endif %}
|
|
34
|
+
{%- if cookiecutter.cache == "redis" or cookiecutter.streaming == "redis_pubsub" %}
|
|
35
|
+
|
|
36
|
+
# Redis
|
|
37
|
+
REDIS_URL=redis://redis:6379/0
|
|
38
|
+
{%- endif %}
|
|
39
|
+
{%- if cookiecutter.cache == "memcached" %}
|
|
40
|
+
|
|
41
|
+
# Memcached
|
|
42
|
+
MEMCACHED_HOST=memcached
|
|
43
|
+
MEMCACHED_PORT=11211
|
|
44
|
+
{%- endif %}
|
|
45
|
+
{%- if cookiecutter.streaming == "kafka" %}
|
|
46
|
+
|
|
47
|
+
# Kafka
|
|
48
|
+
KAFKA_BOOTSTRAP_SERVERS=kafka:9092
|
|
49
|
+
KAFKA_TOPIC={{ cookiecutter.project_slug }}-events
|
|
50
|
+
KAFKA_GROUP_ID={{ cookiecutter.project_slug }}-group
|
|
51
|
+
{%- endif %}
|
|
52
|
+
{%- if cookiecutter.streaming == "rabbitmq" %}
|
|
53
|
+
|
|
54
|
+
# RabbitMQ
|
|
55
|
+
RABBITMQ_URL=amqp://guest:guest@rabbitmq:5672/
|
|
56
|
+
{%- endif %}
|
|
57
|
+
{%- if cookiecutter.streaming == "nats" %}
|
|
58
|
+
|
|
59
|
+
# NATS
|
|
60
|
+
NATS_URL=nats://nats:4222
|
|
61
|
+
{%- endif %}
|
|
62
|
+
{%- if cookiecutter.secrets == "vault" %}
|
|
63
|
+
|
|
64
|
+
# Vault
|
|
65
|
+
VAULT_URL=http://vault:8200
|
|
66
|
+
VAULT_TOKEN=dev-only-token
|
|
67
|
+
VAULT_MOUNT_POINT=secret
|
|
68
|
+
VAULT_SECRET_PATH={{ cookiecutter.project_slug }}
|
|
69
|
+
{%- endif %}
|
|
70
|
+
{%- if cookiecutter.secrets == "aws_sm" %}
|
|
71
|
+
|
|
72
|
+
# AWS Secrets Manager
|
|
73
|
+
AWS_REGION=us-east-1
|
|
74
|
+
AWS_SECRET_NAME={{ cookiecutter.project_slug }}
|
|
75
|
+
{%- endif %}
|
|
76
|
+
{%- if cookiecutter.secrets == "azure_kv" %}
|
|
77
|
+
|
|
78
|
+
# Azure Key Vault
|
|
79
|
+
AZURE_VAULT_URL=https://your-vault.vault.azure.net
|
|
80
|
+
{%- endif %}
|
|
81
|
+
{%- if cookiecutter.secrets == "gcp_sm" %}
|
|
82
|
+
|
|
83
|
+
# GCP Secret Manager
|
|
84
|
+
GCP_PROJECT_ID=your-project-id
|
|
85
|
+
GCP_SECRET_NAME={{ cookiecutter.project_slug }}
|
|
86
|
+
{%- endif %}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
3
|
+
rev: v0.8.0
|
|
4
|
+
hooks:
|
|
5
|
+
- id: ruff
|
|
6
|
+
args: [--fix]
|
|
7
|
+
- id: ruff-format
|
|
8
|
+
|
|
9
|
+
- repo: local
|
|
10
|
+
hooks:
|
|
11
|
+
- id: pytest
|
|
12
|
+
name: pytest
|
|
13
|
+
entry: pytest tests/ -x -q
|
|
14
|
+
language: system
|
|
15
|
+
pass_filenames: false
|
|
16
|
+
always_run: true
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
FROM python:{{cookiecutter.python_version}}-slim AS base
|
|
2
|
+
|
|
3
|
+
WORKDIR /app
|
|
4
|
+
|
|
5
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
6
|
+
curl \
|
|
7
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
8
|
+
|
|
9
|
+
COPY pyproject.toml .
|
|
10
|
+
RUN pip install --no-cache-dir .
|
|
11
|
+
|
|
12
|
+
COPY app/ app/
|
|
13
|
+
{%- if cookiecutter.log_connector == "file" %}
|
|
14
|
+
|
|
15
|
+
RUN mkdir -p /var/log/app
|
|
16
|
+
{%- endif %}
|
|
17
|
+
|
|
18
|
+
RUN adduser --disabled-password --gecos "" appuser \
|
|
19
|
+
{%- if cookiecutter.log_connector == "file" %}
|
|
20
|
+
&& chown -R appuser:appuser /var/log/app
|
|
21
|
+
{%- endif %}
|
|
22
|
+
|
|
23
|
+
USER appuser
|
|
24
|
+
|
|
25
|
+
EXPOSE {{cookiecutter.port}}
|
|
26
|
+
|
|
27
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
|
28
|
+
CMD curl -f http://localhost:{{cookiecutter.port}}/health || exit 1
|
|
29
|
+
|
|
30
|
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "{{cookiecutter.port}}", "--log-level", "info"]
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# {{ cookiecutter.project_name }}
|
|
2
|
+
|
|
3
|
+
> {{ cookiecutter.description }}
|
|
4
|
+
|
|
5
|
+
**Author:** {{ cookiecutter.author_name }}
|
|
6
|
+
**Python:** {{ cookiecutter.python_version }}
|
|
7
|
+
**Port:** {{ cookiecutter.port }}
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Install dependencies
|
|
15
|
+
pip install -e ".[dev]"
|
|
16
|
+
|
|
17
|
+
# Run development server
|
|
18
|
+
uvicorn app.main:app --reload --port {{ cookiecutter.port }}
|
|
19
|
+
```
|
|
20
|
+
{%- if cookiecutter.docker == "yes" %}
|
|
21
|
+
|
|
22
|
+
### With Docker
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# Start all services
|
|
26
|
+
docker compose up --build
|
|
27
|
+
|
|
28
|
+
# Logs
|
|
29
|
+
docker compose logs -f app
|
|
30
|
+
```
|
|
31
|
+
{%- if cookiecutter.docker_debug == "yes" %}
|
|
32
|
+
|
|
33
|
+
### Debug Mode (VS Code)
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
docker compose -f docker-compose.debug.yml up
|
|
37
|
+
# Attach VS Code debugger to port 5678
|
|
38
|
+
```
|
|
39
|
+
{%- endif %}
|
|
40
|
+
{%- endif %}
|
|
41
|
+
|
|
42
|
+
## API Endpoints
|
|
43
|
+
|
|
44
|
+
| Method | Path | Description |
|
|
45
|
+
|--------|------|-------------|
|
|
46
|
+
| `GET` | `/health` | Health check |
|
|
47
|
+
| `GET` | `/api/v1/{{ cookiecutter.model_name_plural }}` | List {{ cookiecutter.model_name_plural }} |
|
|
48
|
+
| `POST` | `/api/v1/{{ cookiecutter.model_name_plural }}` | Create {{ cookiecutter.model_name }} |
|
|
49
|
+
| `GET` | `/api/v1/{{ cookiecutter.model_name_plural }}/{id}` | Get {{ cookiecutter.model_name }} |
|
|
50
|
+
| `PUT` | `/api/v1/{{ cookiecutter.model_name_plural }}/{id}` | Update {{ cookiecutter.model_name }} |
|
|
51
|
+
| `DELETE` | `/api/v1/{{ cookiecutter.model_name_plural }}/{id}` | Delete {{ cookiecutter.model_name }} |
|
|
52
|
+
|
|
53
|
+
## Architecture (SOLID)
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
app/
|
|
57
|
+
├── main.py # App factory, middleware
|
|
58
|
+
├── config.py # Settings (pydantic-settings)
|
|
59
|
+
├── dependencies.py # FastAPI DI wiring
|
|
60
|
+
├── api/
|
|
61
|
+
│ ├── exception_handlers.py # Structured error responses
|
|
62
|
+
│ ├── routes/
|
|
63
|
+
│ │ ├── health.py # Health + readiness
|
|
64
|
+
│ │ └── {{ cookiecutter.model_name_plural }}.py # CRUD routes
|
|
65
|
+
│ └── models/
|
|
66
|
+
│ └── {{ cookiecutter.model_name }}.py # Pydantic schemas
|
|
67
|
+
├── services/
|
|
68
|
+
│ └── {{ cookiecutter.model_name }}_service.py # Business logic
|
|
69
|
+
├── repositories/
|
|
70
|
+
│ └── {{ cookiecutter.model_name }}_repository.py # Data access
|
|
71
|
+
{%- if cookiecutter.database != "none" %}
|
|
72
|
+
├── db/models/
|
|
73
|
+
│ └── {{ cookiecutter.model_name }}.py # DB model
|
|
74
|
+
{%- endif %}
|
|
75
|
+
└── middleware/
|
|
76
|
+
├── security_headers.py # Security headers
|
|
77
|
+
└── logging_middleware.py # Request logging
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Tests
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pytest tests/ -v
|
|
84
|
+
pytest tests/ --cov=app --cov-report=html
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Features
|
|
90
|
+
{%- if cookiecutter.logging == "structlog" %}
|
|
91
|
+
|
|
92
|
+
### Structured Logging
|
|
93
|
+
|
|
94
|
+
- **Library:** structlog
|
|
95
|
+
- **Format:** {{ cookiecutter.log_format }}
|
|
96
|
+
{%- if cookiecutter.log_connector != "stdout" %}
|
|
97
|
+
- **Connector:** {{ cookiecutter.log_connector }}
|
|
98
|
+
{%- endif %}
|
|
99
|
+
- Request correlation IDs via `X-Request-ID` header
|
|
100
|
+
{%- endif %}
|
|
101
|
+
{%- if cookiecutter.database == "postgres" %}
|
|
102
|
+
|
|
103
|
+
### Database (PostgreSQL)
|
|
104
|
+
|
|
105
|
+
- **Driver:** asyncpg via SQLAlchemy async
|
|
106
|
+
- Configure via `DATABASE_URL`
|
|
107
|
+
{%- endif %}
|
|
108
|
+
{%- if cookiecutter.database == "mysql" %}
|
|
109
|
+
|
|
110
|
+
### Database (MySQL)
|
|
111
|
+
|
|
112
|
+
- **Driver:** aiomysql via SQLAlchemy async
|
|
113
|
+
- Configure via `DATABASE_URL`
|
|
114
|
+
{%- endif %}
|
|
115
|
+
{%- if cookiecutter.database == "sqlite" %}
|
|
116
|
+
|
|
117
|
+
### Database (SQLite)
|
|
118
|
+
|
|
119
|
+
- **Driver:** aiosqlite via SQLAlchemy async
|
|
120
|
+
- Configure via `DATABASE_URL`
|
|
121
|
+
{%- endif %}
|
|
122
|
+
{%- if cookiecutter.database == "mongodb" %}
|
|
123
|
+
|
|
124
|
+
### Database (MongoDB)
|
|
125
|
+
|
|
126
|
+
- **Driver:** Motor (async)
|
|
127
|
+
- Configure via `MONGODB_URL`, `MONGODB_DATABASE`
|
|
128
|
+
{%- endif %}
|
|
129
|
+
{%- if cookiecutter.cache == "redis" %}
|
|
130
|
+
|
|
131
|
+
### Cache (Redis)
|
|
132
|
+
|
|
133
|
+
- **Library:** redis-py (async)
|
|
134
|
+
- Configure via `REDIS_URL`
|
|
135
|
+
- Usage: `from app.cache import get_cache`
|
|
136
|
+
{%- endif %}
|
|
137
|
+
{%- if cookiecutter.cache == "memcached" %}
|
|
138
|
+
|
|
139
|
+
### Cache (Memcached)
|
|
140
|
+
|
|
141
|
+
- **Library:** aiomcache
|
|
142
|
+
- Configure via `MEMCACHED_HOST`, `MEMCACHED_PORT`
|
|
143
|
+
- Usage: `from app.cache import get_cache`
|
|
144
|
+
{%- endif %}
|
|
145
|
+
{%- if cookiecutter.cache == "in_memory" %}
|
|
146
|
+
|
|
147
|
+
### Cache (In-Memory)
|
|
148
|
+
|
|
149
|
+
- **Library:** cachetools (TTLCache)
|
|
150
|
+
- 1024 items max, 5 min TTL
|
|
151
|
+
- Usage: `from app.cache import get_cache`
|
|
152
|
+
{%- endif %}
|
|
153
|
+
{%- if cookiecutter.streaming == "kafka" %}
|
|
154
|
+
|
|
155
|
+
### Streaming (Kafka)
|
|
156
|
+
|
|
157
|
+
- **Library:** aiokafka
|
|
158
|
+
- Producer: `from app.streaming.producer import send_event`
|
|
159
|
+
- Consumer auto-starts in lifespan, dispatches to `app/streaming/handler.py`
|
|
160
|
+
- Configure via `KAFKA_BOOTSTRAP_SERVERS`, `KAFKA_GROUP_ID`
|
|
161
|
+
{%- endif %}
|
|
162
|
+
{%- if cookiecutter.streaming == "rabbitmq" %}
|
|
163
|
+
|
|
164
|
+
### Streaming (RabbitMQ)
|
|
165
|
+
|
|
166
|
+
- **Library:** aio-pika
|
|
167
|
+
- Producer: `from app.streaming.producer import send_event`
|
|
168
|
+
- Consumer auto-starts in lifespan, dispatches to `app/streaming/handler.py`
|
|
169
|
+
- Configure via `RABBITMQ_URL`
|
|
170
|
+
{%- endif %}
|
|
171
|
+
{%- if cookiecutter.streaming == "redis_pubsub" %}
|
|
172
|
+
|
|
173
|
+
### Streaming (Redis Pub/Sub)
|
|
174
|
+
|
|
175
|
+
- **Library:** redis-py (async)
|
|
176
|
+
- Producer: `from app.streaming.producer import send_event`
|
|
177
|
+
- Consumer auto-starts in lifespan, dispatches to `app/streaming/handler.py`
|
|
178
|
+
- Configure via `REDIS_URL`
|
|
179
|
+
{%- endif %}
|
|
180
|
+
{%- if cookiecutter.streaming == "nats" %}
|
|
181
|
+
|
|
182
|
+
### Streaming (NATS)
|
|
183
|
+
|
|
184
|
+
- **Library:** nats-py
|
|
185
|
+
- Producer: `from app.streaming.producer import send_event`
|
|
186
|
+
- Consumer auto-starts in lifespan, dispatches to `app/streaming/handler.py`
|
|
187
|
+
- Configure via `NATS_URL`
|
|
188
|
+
{%- endif %}
|
|
189
|
+
{%- if cookiecutter.secrets == "vault" %}
|
|
190
|
+
|
|
191
|
+
### Secrets (HashiCorp Vault)
|
|
192
|
+
|
|
193
|
+
- **Library:** hvac
|
|
194
|
+
- Secrets loaded at startup into `app.state.secrets`
|
|
195
|
+
- Configure via `VAULT_URL`, `VAULT_TOKEN`
|
|
196
|
+
{%- endif %}
|
|
197
|
+
{%- if cookiecutter.secrets == "aws_sm" %}
|
|
198
|
+
|
|
199
|
+
### Secrets (AWS Secrets Manager)
|
|
200
|
+
|
|
201
|
+
- **Library:** boto3
|
|
202
|
+
- Secrets loaded at startup into `app.state.secrets`
|
|
203
|
+
- Configure via `AWS_REGION`, `AWS_SECRET_NAME`
|
|
204
|
+
{%- endif %}
|
|
205
|
+
{%- if cookiecutter.secrets == "azure_kv" %}
|
|
206
|
+
|
|
207
|
+
### Secrets (Azure Key Vault)
|
|
208
|
+
|
|
209
|
+
- **Library:** azure-keyvault-secrets + azure-identity
|
|
210
|
+
- Secrets loaded at startup into `app.state.secrets`
|
|
211
|
+
- Configure via `AZURE_VAULT_URL`
|
|
212
|
+
{%- endif %}
|
|
213
|
+
{%- if cookiecutter.secrets == "gcp_sm" %}
|
|
214
|
+
|
|
215
|
+
### Secrets (GCP Secret Manager)
|
|
216
|
+
|
|
217
|
+
- **Library:** google-cloud-secret-manager
|
|
218
|
+
- Secrets loaded at startup into `app.state.secrets`
|
|
219
|
+
- Configure via `GCP_PROJECT_ID`, `GCP_SECRET_NAME`
|
|
220
|
+
{%- endif %}
|
|
221
|
+
{%- if cookiecutter.quality_gate != "none" %}
|
|
222
|
+
|
|
223
|
+
### Quality Gate
|
|
224
|
+
|
|
225
|
+
- **Tool:** {{ cookiecutter.quality_gate }}
|
|
226
|
+
{%- endif %}
|
|
227
|
+
{%- if cookiecutter.docker == "yes" %}
|
|
228
|
+
|
|
229
|
+
### Containerization
|
|
230
|
+
|
|
231
|
+
- Multi-stage Dockerfile (slim base)
|
|
232
|
+
- Non-root user
|
|
233
|
+
- Health check built-in
|
|
234
|
+
{%- endif %}
|
|
235
|
+
|
|
236
|
+
## Environment
|
|
237
|
+
|
|
238
|
+
Configuration is managed via environment variables. See `.env.staging`.
|
|
239
|
+
|
|
240
|
+
## Extend Your Project
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
fastforge-infra # Infrastructure (Kafka, ES, Vault, DB)
|
|
244
|
+
fastforge-cicd # CI/CD pipeline
|
|
245
|
+
fastforge-secops # Security tools
|
|
246
|
+
fastforge-helm # Helm chart
|
|
247
|
+
fastforge-k8s # Kubernetes manifests
|
|
248
|
+
fastforge-swarm # Docker Swarm stack
|
|
249
|
+
fastforge-observability # Tracing + Metrics
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
*Generated with [FastForge](https://github.com/jinnabaalu/fastforge)*
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Structured exception handlers for consistent API error responses."""
|
|
2
|
+
|
|
3
|
+
import structlog
|
|
4
|
+
from fastapi import FastAPI, Request
|
|
5
|
+
from fastapi.exceptions import RequestValidationError
|
|
6
|
+
from fastapi.responses import JSONResponse
|
|
7
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
8
|
+
|
|
9
|
+
logger = structlog.get_logger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
|
13
|
+
"""Handle HTTP exceptions with a consistent JSON structure."""
|
|
14
|
+
await logger.awarning(
|
|
15
|
+
"http_error",
|
|
16
|
+
status_code=exc.status_code,
|
|
17
|
+
detail=exc.detail,
|
|
18
|
+
path=request.url.path,
|
|
19
|
+
)
|
|
20
|
+
return JSONResponse(
|
|
21
|
+
status_code=exc.status_code,
|
|
22
|
+
content={
|
|
23
|
+
"error": {
|
|
24
|
+
"code": exc.status_code,
|
|
25
|
+
"message": exc.detail,
|
|
26
|
+
"path": request.url.path,
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
33
|
+
"""Handle request validation errors with field-level details."""
|
|
34
|
+
await logger.awarning(
|
|
35
|
+
"validation_error",
|
|
36
|
+
path=request.url.path,
|
|
37
|
+
errors=exc.errors(),
|
|
38
|
+
)
|
|
39
|
+
return JSONResponse(
|
|
40
|
+
status_code=422,
|
|
41
|
+
content={
|
|
42
|
+
"error": {
|
|
43
|
+
"code": 422,
|
|
44
|
+
"message": "Validation error",
|
|
45
|
+
"path": request.url.path,
|
|
46
|
+
"details": [
|
|
47
|
+
{"field": ".".join(str(loc) for loc in e["loc"]), "message": e["msg"]}
|
|
48
|
+
for e in exc.errors()
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
56
|
+
"""Catch-all for unhandled exceptions — never leak stack traces."""
|
|
57
|
+
await logger.aexception(
|
|
58
|
+
"unhandled_error",
|
|
59
|
+
path=request.url.path,
|
|
60
|
+
exc_type=type(exc).__name__,
|
|
61
|
+
)
|
|
62
|
+
return JSONResponse(
|
|
63
|
+
status_code=500,
|
|
64
|
+
content={
|
|
65
|
+
"error": {
|
|
66
|
+
"code": 500,
|
|
67
|
+
"message": "Internal server error",
|
|
68
|
+
"path": request.url.path,
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def register_exception_handlers(app: FastAPI) -> None:
|
|
75
|
+
"""Register all exception handlers on the app."""
|
|
76
|
+
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
|
|
77
|
+
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
|
78
|
+
app.add_exception_handler(Exception, unhandled_exception_handler)
|
|
File without changes
|
fastforge/template/{{cookiecutter.project_slug}}/app/api/models/{{cookiecutter.model_name}}.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class {{ cookiecutter.model_name_class }}Create(BaseModel):
|
|
5
|
+
name: str = Field(..., min_length=1, max_length=255)
|
|
6
|
+
description: str | None = Field(None, max_length=1000)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class {{ cookiecutter.model_name_class }}Update(BaseModel):
|
|
10
|
+
name: str | None = Field(None, min_length=1, max_length=255)
|
|
11
|
+
description: str | None = Field(None, max_length=1000)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class {{ cookiecutter.model_name_class }}Response(BaseModel):
|
|
15
|
+
id: str
|
|
16
|
+
name: str
|
|
17
|
+
description: str | None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class {{ cookiecutter.model_name_class }}ListResponse(BaseModel):
|
|
21
|
+
{{ cookiecutter.model_name_plural }}: list[{{ cookiecutter.model_name_class }}Response]
|
|
22
|
+
total: int
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from fastapi import APIRouter
|
|
2
|
+
|
|
3
|
+
from app.config import settings
|
|
4
|
+
|
|
5
|
+
router = APIRouter(tags=["health"])
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@router.get("/health")
|
|
9
|
+
async def health_check() -> dict:
|
|
10
|
+
return {
|
|
11
|
+
"status": "healthy",
|
|
12
|
+
"service": settings.app_name,
|
|
13
|
+
"version": settings.app_version,
|
|
14
|
+
"environment": settings.app_env,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.get("/ready")
|
|
19
|
+
async def readiness_check() -> dict:
|
|
20
|
+
return {"status": "ready"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{% if cookiecutter.logging == "structlog" -%}
|
|
2
|
+
from app.logging_config import get_logger
|
|
3
|
+
{%- else -%}
|
|
4
|
+
from logging import getLogger
|
|
5
|
+
{%- endif %}
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
7
|
+
|
|
8
|
+
from app.api.models.{{cookiecutter.model_name}} import (
|
|
9
|
+
{{cookiecutter.model_name_class}}Create,
|
|
10
|
+
{{cookiecutter.model_name_class}}ListResponse,
|
|
11
|
+
{{cookiecutter.model_name_class}}Response,
|
|
12
|
+
{{cookiecutter.model_name_class}}Update,
|
|
13
|
+
)
|
|
14
|
+
from app.dependencies import get_{{cookiecutter.model_name}}_service
|
|
15
|
+
from app.services.{{cookiecutter.model_name}}_service import {{cookiecutter.model_name_class}}Service
|
|
16
|
+
|
|
17
|
+
router = APIRouter(prefix="/api/v1/{{cookiecutter.model_name_plural}}", tags=["{{cookiecutter.model_name_plural}}"])
|
|
18
|
+
{% if cookiecutter.logging == "structlog" -%}
|
|
19
|
+
logger = get_logger(__name__)
|
|
20
|
+
{%- else -%}
|
|
21
|
+
logger = getLogger(__name__)
|
|
22
|
+
{%- endif %}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.post("/", response_model={{cookiecutter.model_name_class}}Response, status_code=201)
|
|
26
|
+
async def create_{{cookiecutter.model_name}}(
|
|
27
|
+
data: {{cookiecutter.model_name_class}}Create,
|
|
28
|
+
service: {{cookiecutter.model_name_class}}Service = Depends(get_{{cookiecutter.model_name}}_service),
|
|
29
|
+
) -> {{cookiecutter.model_name_class}}Response:
|
|
30
|
+
return await service.create(data)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.get("/", response_model={{cookiecutter.model_name_class}}ListResponse)
|
|
34
|
+
async def list_{{cookiecutter.model_name_plural}}(
|
|
35
|
+
service: {{cookiecutter.model_name_class}}Service = Depends(get_{{cookiecutter.model_name}}_service),
|
|
36
|
+
) -> {{cookiecutter.model_name_class}}ListResponse:
|
|
37
|
+
return await service.list_all()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.get("/{item_id}", response_model={{cookiecutter.model_name_class}}Response)
|
|
41
|
+
async def get_{{cookiecutter.model_name}}(
|
|
42
|
+
item_id: str,
|
|
43
|
+
service: {{cookiecutter.model_name_class}}Service = Depends(get_{{cookiecutter.model_name}}_service),
|
|
44
|
+
) -> {{cookiecutter.model_name_class}}Response:
|
|
45
|
+
result = await service.get_by_id(item_id)
|
|
46
|
+
if not result:
|
|
47
|
+
raise HTTPException(status_code=404, detail="{{cookiecutter.model_name_class}} not found")
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.put("/{item_id}", response_model={{cookiecutter.model_name_class}}Response)
|
|
52
|
+
async def update_{{cookiecutter.model_name}}(
|
|
53
|
+
item_id: str,
|
|
54
|
+
data: {{cookiecutter.model_name_class}}Update,
|
|
55
|
+
service: {{cookiecutter.model_name_class}}Service = Depends(get_{{cookiecutter.model_name}}_service),
|
|
56
|
+
) -> {{cookiecutter.model_name_class}}Response:
|
|
57
|
+
result = await service.update(item_id, data)
|
|
58
|
+
if not result:
|
|
59
|
+
raise HTTPException(status_code=404, detail="{{cookiecutter.model_name_class}} not found")
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.delete("/{item_id}", status_code=204)
|
|
64
|
+
async def delete_{{cookiecutter.model_name}}(
|
|
65
|
+
item_id: str,
|
|
66
|
+
service: {{cookiecutter.model_name_class}}Service = Depends(get_{{cookiecutter.model_name}}_service),
|
|
67
|
+
) -> None:
|
|
68
|
+
deleted = await service.delete(item_id)
|
|
69
|
+
if not deleted:
|
|
70
|
+
raise HTTPException(status_code=404, detail="{{cookiecutter.model_name_class}} not found")
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Cache client — {{ cookiecutter.cache }} backend."""
|
|
2
|
+
{%- if cookiecutter.cache == "redis" %}
|
|
3
|
+
|
|
4
|
+
import redis.asyncio as redis
|
|
5
|
+
|
|
6
|
+
from app.config import settings
|
|
7
|
+
|
|
8
|
+
_client: redis.Redis | None = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def get_cache() -> redis.Redis:
|
|
12
|
+
"""Get the Redis cache client (lazy init)."""
|
|
13
|
+
global _client # noqa: PLW0603
|
|
14
|
+
if _client is None:
|
|
15
|
+
_client = redis.from_url(settings.redis_url, decode_responses=True)
|
|
16
|
+
return _client
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def close_cache() -> None:
|
|
20
|
+
"""Close the Redis connection pool."""
|
|
21
|
+
global _client # noqa: PLW0603
|
|
22
|
+
if _client is not None:
|
|
23
|
+
await _client.aclose()
|
|
24
|
+
_client = None
|
|
25
|
+
{%- elif cookiecutter.cache == "memcached" %}
|
|
26
|
+
|
|
27
|
+
import aiomcache
|
|
28
|
+
|
|
29
|
+
from app.config import settings
|
|
30
|
+
|
|
31
|
+
_client: aiomcache.Client | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_cache() -> aiomcache.Client:
|
|
35
|
+
"""Get the Memcached client (lazy init)."""
|
|
36
|
+
global _client # noqa: PLW0603
|
|
37
|
+
if _client is None:
|
|
38
|
+
_client = aiomcache.Client(settings.memcached_host, settings.memcached_port)
|
|
39
|
+
return _client
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def close_cache() -> None:
|
|
43
|
+
"""Close the Memcached connection."""
|
|
44
|
+
global _client # noqa: PLW0603
|
|
45
|
+
if _client is not None:
|
|
46
|
+
await _client.close()
|
|
47
|
+
_client = None
|
|
48
|
+
{%- elif cookiecutter.cache == "in_memory" %}
|
|
49
|
+
|
|
50
|
+
from cachetools import TTLCache
|
|
51
|
+
|
|
52
|
+
_cache: TTLCache = TTLCache(maxsize=1024, ttl=300)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_cache() -> TTLCache:
|
|
56
|
+
"""Get the in-memory TTL cache (1024 items, 5 min TTL)."""
|
|
57
|
+
return _cache
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def close_cache() -> None:
|
|
61
|
+
"""Clear the in-memory cache."""
|
|
62
|
+
_cache.clear()
|
|
63
|
+
{%- endif %}
|