model-generator-kit 0.1.0__tar.gz → 0.1.2__tar.gz
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.
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/PKG-INFO +2 -2
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/README.md +1 -1
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/pyproject.toml +1 -1
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/__init__.py +1 -1
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/generators/infrastructure.py +80 -1
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/config.yaml +12 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/api/route.py.j2 +19 -15
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/errors.py.j2 +56 -2
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/main.py.j2 +46 -5
- model_generator_kit-0.1.2/src/model_generator/stacks/python-fastapi/templates/infrastructure/request_limit.py.j2 +124 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator_kit.egg-info/PKG-INFO +2 -2
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator_kit.egg-info/SOURCES.txt +1 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_generators.py +508 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/LICENSE +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/setup.cfg +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/generate.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/generators/__init__.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/generators/api.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/generators/constraints.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/generators/database.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/generators/enums.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/generators/migrations.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/py.typed +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/schema/model.schema.json +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/_shared/_base.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/_shared/_entity.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/_shared/_examples.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/_shared/_fields.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/_shared/_tests.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/api/init.py.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/api/pagination.py.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/api/request.py.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/api/response.py.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/database/constraints.py.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/database/enums.py.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/database/factory.py.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/database/init.py.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/database/model.py.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/auth_router.py.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/base.py.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/csrf.py.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/database_init.py.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/encrypted_bytes.py.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/engine.py.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/gitignore.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/pyproject.toml.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/rate_limit.py.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/types.py.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/utils.py.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/validators.py.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/migrations/env.py.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/migrations/ini.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/migrations/script.py.mako.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/tests/conftest_root.py.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/tests/contract.py.j2 +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/utils/__init__.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/utils/conftest_generator.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/utils/constants.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/utils/loaders.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/utils/parser.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/utils/quality.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/utils/templates.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/validate.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/wizard/__init__.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/wizard/actions/__init__.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/wizard/actions/clean.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/wizard/actions/generate.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/wizard/actions/project_setup.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/wizard/actions/test_runner.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/wizard/menu.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/wizard/prompts.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator_kit.egg-info/dependency_links.txt +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator_kit.egg-info/entry_points.txt +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator_kit.egg-info/requires.txt +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator_kit.egg-info/top_level.txt +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_cleanup.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_cli.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_edge_cases.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_enum_examples.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_full_generation.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_integration.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_template_utils.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_utils.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_validate.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_validation.py +0 -0
- {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_wizard.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: model-generator-kit
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: One-shot bootstrap generator for database models, API models, routes, and tests
|
|
5
5
|
Author-email: nuncaeslupus <imarcos@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -140,4 +140,4 @@ Define specifications once in JSON. Generate production-ready scaffolds. Then ma
|
|
|
140
140
|
|
|
141
141
|
---
|
|
142
142
|
|
|
143
|
-
**Model Generator** | Bootstrap Tool for API Backends | v0.1.
|
|
143
|
+
**Model Generator** | Bootstrap Tool for API Backends | v0.1.2
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "model-generator-kit"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.2"
|
|
8
8
|
description = "One-shot bootstrap generator for database models, API models, routes, and tests"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -10,6 +10,37 @@ from jinja2 import Environment
|
|
|
10
10
|
from ..utils.constants import GENERATED_MARKER
|
|
11
11
|
from ..utils.templates import path_to_import
|
|
12
12
|
|
|
13
|
+
# Generous default request-body cap (10 MiB) — large enough that normal JSON /
|
|
14
|
+
# base64 payloads are never affected, small enough to blunt large-body soft-DoS.
|
|
15
|
+
DEFAULT_MAX_REQUEST_BODY_BYTES = 10 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _app_config(config: dict[str, Any]) -> dict[str, Any]:
|
|
19
|
+
"""Return the ``app`` config section as a dict.
|
|
20
|
+
|
|
21
|
+
Falls back to an empty dict when the key is absent or misconfigured as a
|
|
22
|
+
non-mapping value (e.g. ``app: true``), so callers can ``.get()`` without
|
|
23
|
+
risking an ``AttributeError``.
|
|
24
|
+
"""
|
|
25
|
+
app_config = config.get("app")
|
|
26
|
+
return app_config if isinstance(app_config, dict) else {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _max_request_body_bytes(config: dict[str, Any]) -> int:
|
|
30
|
+
"""Resolve the configured request-body cap in bytes.
|
|
31
|
+
|
|
32
|
+
Reads ``app.max_request_body_bytes`` (falling back to the generous
|
|
33
|
+
default). A non-positive value disables the limit: the middleware is not
|
|
34
|
+
emitted and ``main.py`` does not wire it.
|
|
35
|
+
"""
|
|
36
|
+
value = _app_config(config).get(
|
|
37
|
+
"max_request_body_bytes", DEFAULT_MAX_REQUEST_BODY_BYTES
|
|
38
|
+
)
|
|
39
|
+
try:
|
|
40
|
+
return int(value)
|
|
41
|
+
except (TypeError, ValueError):
|
|
42
|
+
return DEFAULT_MAX_REQUEST_BODY_BYTES
|
|
43
|
+
|
|
13
44
|
|
|
14
45
|
def generate_base(
|
|
15
46
|
config: dict[str, Any], env: Environment, project_root: Path
|
|
@@ -88,8 +119,14 @@ def generate_errors(
|
|
|
88
119
|
if output_path.exists():
|
|
89
120
|
return None
|
|
90
121
|
|
|
122
|
+
expose_integrity_error_fields = bool(
|
|
123
|
+
_app_config(config).get("expose_integrity_error_fields", False)
|
|
124
|
+
)
|
|
125
|
+
|
|
91
126
|
template = env.get_template("infrastructure/errors.py.j2")
|
|
92
|
-
content = template.render(
|
|
127
|
+
content = template.render(
|
|
128
|
+
expose_integrity_error_fields=expose_integrity_error_fields
|
|
129
|
+
)
|
|
93
130
|
|
|
94
131
|
return {"path": output_path, "content": content}
|
|
95
132
|
|
|
@@ -135,6 +172,31 @@ def generate_utils(
|
|
|
135
172
|
return {"path": output_path, "content": content}
|
|
136
173
|
|
|
137
174
|
|
|
175
|
+
def generate_request_limit(
|
|
176
|
+
config: dict[str, Any], env: Environment, project_root: Path
|
|
177
|
+
) -> dict[str, Any] | None:
|
|
178
|
+
"""Generate the request-body size-limit ASGI middleware.
|
|
179
|
+
|
|
180
|
+
Bootstrap-only. Emitted by default (when ``app.max_request_body_bytes``
|
|
181
|
+
is a positive integer); setting it to 0 disables the limit and skips
|
|
182
|
+
emission. Lives next to the other API-layer infrastructure files.
|
|
183
|
+
"""
|
|
184
|
+
if _max_request_body_bytes(config) <= 0:
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
api_models_path = config["paths"].get("api_models", "backend/src/api/models")
|
|
188
|
+
api_dir = str(Path(api_models_path).parent)
|
|
189
|
+
output_path = project_root / api_dir / "request_limit.py"
|
|
190
|
+
|
|
191
|
+
if output_path.exists():
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
template = env.get_template("infrastructure/request_limit.py.j2")
|
|
195
|
+
content = template.render()
|
|
196
|
+
|
|
197
|
+
return {"path": output_path, "content": content}
|
|
198
|
+
|
|
199
|
+
|
|
138
200
|
def generate_gitignore(
|
|
139
201
|
config: dict[str, Any],
|
|
140
202
|
env: Environment,
|
|
@@ -280,6 +342,19 @@ def generate_main(
|
|
|
280
342
|
main_dir = str(Path(main_path).parent)
|
|
281
343
|
main_module = path_to_import(main_dir, "main", python_root=python_root)
|
|
282
344
|
|
|
345
|
+
errors_path = config["paths"].get("errors", "backend/src/api/errors.py")
|
|
346
|
+
errors_module = errors_path[:-3] if errors_path.endswith(".py") else errors_path
|
|
347
|
+
errors_import = path_to_import(errors_module, python_root=python_root)
|
|
348
|
+
|
|
349
|
+
max_request_body_bytes = _max_request_body_bytes(config)
|
|
350
|
+
request_limit_module_import = None
|
|
351
|
+
if max_request_body_bytes > 0:
|
|
352
|
+
api_models_path = config["paths"].get("api_models", "backend/src/api/models")
|
|
353
|
+
api_dir = str(Path(api_models_path).parent)
|
|
354
|
+
request_limit_module_import = path_to_import(
|
|
355
|
+
str(Path(api_dir) / "request_limit"), python_root=python_root
|
|
356
|
+
)
|
|
357
|
+
|
|
283
358
|
auth_router_import = None
|
|
284
359
|
auth = config.get("auth") or {}
|
|
285
360
|
if auth.get("strategy"):
|
|
@@ -313,6 +388,9 @@ def generate_main(
|
|
|
313
388
|
auth_router_import=auth_router_import,
|
|
314
389
|
csrf_module_import=csrf_module_import,
|
|
315
390
|
rate_limit_module_import=rate_limit_module_import,
|
|
391
|
+
errors_import=errors_import,
|
|
392
|
+
request_limit_module_import=request_limit_module_import,
|
|
393
|
+
max_request_body_bytes=max_request_body_bytes,
|
|
316
394
|
)
|
|
317
395
|
|
|
318
396
|
return {"path": output_path, "content": content}
|
|
@@ -629,6 +707,7 @@ def generate_infrastructure(
|
|
|
629
707
|
generate_errors(config, env, project_root),
|
|
630
708
|
generate_validators(config, env, project_root),
|
|
631
709
|
generate_utils(config, env, project_root),
|
|
710
|
+
generate_request_limit(config, env, project_root),
|
|
632
711
|
generate_main(
|
|
633
712
|
config,
|
|
634
713
|
env,
|
|
@@ -53,6 +53,18 @@ paths:
|
|
|
53
53
|
constraints: backend/src/database/models/constraints.py
|
|
54
54
|
migrations: alembic
|
|
55
55
|
|
|
56
|
+
# Application hardening settings (override per-project in .model-generator.yaml).
|
|
57
|
+
app:
|
|
58
|
+
# Reject HTTP request bodies larger than this many bytes with a 413 before
|
|
59
|
+
# they are read into memory (defense-in-depth against large-body soft-DoS).
|
|
60
|
+
# Generous default so normal JSON / base64 payloads are never affected.
|
|
61
|
+
# Set to 0 to disable the limit (no middleware is emitted).
|
|
62
|
+
max_request_body_bytes: 10485760 # 10 MiB
|
|
63
|
+
# When true, the 409 duplicate-value error names the offending column
|
|
64
|
+
# ("...with this <field> already exists"). Off by default so the generated
|
|
65
|
+
# error body never leaks internal schema/column names.
|
|
66
|
+
expose_integrity_error_fields: false
|
|
67
|
+
|
|
56
68
|
# Type mappings: abstract -> concrete
|
|
57
69
|
types:
|
|
58
70
|
uuid:
|
|
@@ -75,7 +75,7 @@ All endpoints follow RESTful conventions and return standardized error responses
|
|
|
75
75
|
import bcrypt
|
|
76
76
|
{% endif %}
|
|
77
77
|
{% if ns.has_datetime %}
|
|
78
|
-
from datetime import datetime
|
|
78
|
+
from datetime import datetime, timezone
|
|
79
79
|
{% endif %}
|
|
80
80
|
{% if ns.has_financial or ns.has_percentage %}
|
|
81
81
|
from decimal import Decimal
|
|
@@ -179,11 +179,11 @@ async def list_{{ entity_plural }}(
|
|
|
179
179
|
{% elif field.type == 'boolean' %}
|
|
180
180
|
{{ field_name }}: bool | None = Query(None, description="Filter by {{ field_name }}"),
|
|
181
181
|
{% elif field.type == 'datetime' %}
|
|
182
|
-
{{ field_name }}_after:
|
|
183
|
-
{{ field_name }}_before:
|
|
182
|
+
{{ field_name }}_after: datetime | None = Query(None, description="Filter {{ field_name }} after this datetime (ISO 8601)"),
|
|
183
|
+
{{ field_name }}_before: datetime | None = Query(None, description="Filter {{ field_name }} before this datetime (ISO 8601)"),
|
|
184
184
|
{% elif field.type in ['financial', 'percentage'] %}
|
|
185
|
-
{{ field_name }}_min:
|
|
186
|
-
{{ field_name }}_max:
|
|
185
|
+
{{ field_name }}_min: Decimal | None = Query(None, description="Filter {{ field_name }} minimum value"),
|
|
186
|
+
{{ field_name }}_max: Decimal | None = Query(None, description="Filter {{ field_name }} maximum value"),
|
|
187
187
|
{% elif field.type == 'counter' %}
|
|
188
188
|
{{ field_name }}_min: int | None = Query(None, description="Filter {{ field_name }} minimum value"),
|
|
189
189
|
{{ field_name }}_max: int | None = Query(None, description="Filter {{ field_name }} maximum value"),
|
|
@@ -225,20 +225,24 @@ async def list_{{ entity_plural }}(
|
|
|
225
225
|
count_stmt = count_stmt.where({{ entity_name }}.{{ field_name }} == {{ field_name }})
|
|
226
226
|
{% elif field.type == 'datetime' %}
|
|
227
227
|
if {{ field_name }}_after is not None:
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
228
|
+
# Localize a naive value to UTC: the column is tz-aware, and comparing
|
|
229
|
+
# it against a naive datetime raises on strict drivers (asyncpg/psycopg2).
|
|
230
|
+
if {{ field_name }}_after.tzinfo is None:
|
|
231
|
+
{{ field_name }}_after = {{ field_name }}_after.replace(tzinfo=timezone.utc)
|
|
232
|
+
stmt = stmt.where({{ entity_name }}.{{ field_name }} >= {{ field_name }}_after)
|
|
233
|
+
count_stmt = count_stmt.where({{ entity_name }}.{{ field_name }} >= {{ field_name }}_after)
|
|
231
234
|
if {{ field_name }}_before is not None:
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
+
if {{ field_name }}_before.tzinfo is None:
|
|
236
|
+
{{ field_name }}_before = {{ field_name }}_before.replace(tzinfo=timezone.utc)
|
|
237
|
+
stmt = stmt.where({{ entity_name }}.{{ field_name }} <= {{ field_name }}_before)
|
|
238
|
+
count_stmt = count_stmt.where({{ entity_name }}.{{ field_name }} <= {{ field_name }}_before)
|
|
235
239
|
{% elif field.type in ['financial', 'percentage'] %}
|
|
236
240
|
if {{ field_name }}_min is not None:
|
|
237
|
-
stmt = stmt.where({{ entity_name }}.{{ field_name }} >=
|
|
238
|
-
count_stmt = count_stmt.where({{ entity_name }}.{{ field_name }} >=
|
|
241
|
+
stmt = stmt.where({{ entity_name }}.{{ field_name }} >= {{ field_name }}_min)
|
|
242
|
+
count_stmt = count_stmt.where({{ entity_name }}.{{ field_name }} >= {{ field_name }}_min)
|
|
239
243
|
if {{ field_name }}_max is not None:
|
|
240
|
-
stmt = stmt.where({{ entity_name }}.{{ field_name }} <=
|
|
241
|
-
count_stmt = count_stmt.where({{ entity_name }}.{{ field_name }} <=
|
|
244
|
+
stmt = stmt.where({{ entity_name }}.{{ field_name }} <= {{ field_name }}_max)
|
|
245
|
+
count_stmt = count_stmt.where({{ entity_name }}.{{ field_name }} <= {{ field_name }}_max)
|
|
242
246
|
{% elif field.type == 'counter' %}
|
|
243
247
|
if {{ field_name }}_min is not None:
|
|
244
248
|
stmt = stmt.where({{ entity_name }}.{{ field_name }} >= {{ field_name }}_min)
|
|
@@ -10,10 +10,15 @@ API error formatting utilities.
|
|
|
10
10
|
|
|
11
11
|
Provides consistent error response formatting for:
|
|
12
12
|
- Database integrity errors (unique constraints, foreign keys)
|
|
13
|
+
- Request validation errors (trimmed pydantic errors)
|
|
13
14
|
- Not found errors
|
|
14
15
|
"""
|
|
15
16
|
|
|
16
|
-
from
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from fastapi import HTTPException, Request, status
|
|
20
|
+
from fastapi.exceptions import RequestValidationError
|
|
21
|
+
from fastapi.responses import JSONResponse
|
|
17
22
|
from sqlalchemy.exc import IntegrityError
|
|
18
23
|
|
|
19
24
|
|
|
@@ -32,12 +37,18 @@ def format_integrity_error(error: IntegrityError, entity_name: str = "Record") -
|
|
|
32
37
|
|
|
33
38
|
# Check for unique constraint violation
|
|
34
39
|
if "UNIQUE constraint failed" in error_str or "duplicate key" in error_str.lower():
|
|
35
|
-
|
|
40
|
+
{% if expose_integrity_error_fields %}
|
|
41
|
+
# Field-name exposure is opt-in (app.expose_integrity_error_fields).
|
|
36
42
|
if "UNIQUE constraint failed:" in error_str:
|
|
37
43
|
field = error_str.split("UNIQUE constraint failed:")[-1].strip()
|
|
38
44
|
message = f"A {entity_name} with this {field} already exists"
|
|
39
45
|
else:
|
|
40
46
|
message = f"A {entity_name} with these values already exists"
|
|
47
|
+
{% else %}
|
|
48
|
+
# Generic message: the parsed column name is withheld so the error
|
|
49
|
+
# body never leaks internal schema details.
|
|
50
|
+
message = f"A {entity_name} with these values already exists"
|
|
51
|
+
{% endif %}
|
|
41
52
|
return HTTPException(
|
|
42
53
|
status_code=status.HTTP_409_CONFLICT,
|
|
43
54
|
detail={"error": "duplicate_value", "message": message},
|
|
@@ -57,6 +68,49 @@ def format_integrity_error(error: IntegrityError, entity_name: str = "Record") -
|
|
|
57
68
|
)
|
|
58
69
|
|
|
59
70
|
|
|
71
|
+
async def validation_exception_handler(
|
|
72
|
+
request: Request, exc: Exception
|
|
73
|
+
) -> JSONResponse:
|
|
74
|
+
"""Return a trimmed 422 body for request-validation failures.
|
|
75
|
+
|
|
76
|
+
FastAPI's default handler serializes ``exc.errors()`` verbatim, which
|
|
77
|
+
echoes the submitted input values and internal locator detail. This
|
|
78
|
+
summarizes each error to its field path and message so neither the
|
|
79
|
+
submitted values nor internal locators leak into the response.
|
|
80
|
+
|
|
81
|
+
Registered in the app via::
|
|
82
|
+
|
|
83
|
+
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
|
84
|
+
"""
|
|
85
|
+
fields: list[dict[str, str]] = []
|
|
86
|
+
if isinstance(exc, RequestValidationError):
|
|
87
|
+
for err in exc.errors():
|
|
88
|
+
loc = err.get("loc", ())
|
|
89
|
+
# Strip only the leading source marker (body/query/path/...), not
|
|
90
|
+
# every occurrence — a real field may legitimately be named "body".
|
|
91
|
+
if loc and loc[0] in ("body", "query", "path", "header", "cookie"):
|
|
92
|
+
loc = loc[1:]
|
|
93
|
+
field = ".".join(str(part) for part in loc)
|
|
94
|
+
fields.append(
|
|
95
|
+
{
|
|
96
|
+
"field": field or "request",
|
|
97
|
+
"message": str(err.get("msg", "Invalid input")),
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
payload: dict[str, Any] = {
|
|
101
|
+
"error": "validation_error",
|
|
102
|
+
"message": "Request validation failed",
|
|
103
|
+
"fields": fields,
|
|
104
|
+
}
|
|
105
|
+
return JSONResponse(
|
|
106
|
+
# 422 Unprocessable Entity. Literal rather than status.HTTP_422_* to
|
|
107
|
+
# avoid the Starlette constant rename churn (ENTITY -> CONTENT) across
|
|
108
|
+
# versions, which would otherwise emit a DeprecationWarning.
|
|
109
|
+
status_code=422,
|
|
110
|
+
content=payload,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
60
114
|
def format_not_found_error(entity_name: str, entity_id: object) -> HTTPException:
|
|
61
115
|
"""
|
|
62
116
|
Format a not found error as HTTPException.
|
|
@@ -18,6 +18,7 @@ from contextlib import asynccontextmanager
|
|
|
18
18
|
from collections.abc import AsyncIterator
|
|
19
19
|
|
|
20
20
|
from fastapi import FastAPI
|
|
21
|
+
from fastapi.exceptions import RequestValidationError
|
|
21
22
|
from fastapi.middleware.cors import CORSMiddleware
|
|
22
23
|
|
|
23
24
|
{% for domain in domains %}
|
|
@@ -35,7 +36,11 @@ from slowapi.errors import RateLimitExceeded
|
|
|
35
36
|
|
|
36
37
|
from {{ rate_limit_module_import }} import limiter
|
|
37
38
|
{% endif %}
|
|
39
|
+
{% if request_limit_module_import %}
|
|
40
|
+
from {{ request_limit_module_import }} import RequestBodySizeLimitMiddleware
|
|
41
|
+
{% endif %}
|
|
38
42
|
from {{ db_import }}.engine import engine
|
|
43
|
+
from {{ errors_import }} import validation_exception_handler
|
|
39
44
|
|
|
40
45
|
|
|
41
46
|
@asynccontextmanager
|
|
@@ -52,6 +57,20 @@ app = FastAPI(
|
|
|
52
57
|
lifespan=lifespan,
|
|
53
58
|
)
|
|
54
59
|
|
|
60
|
+
# Validation errors: trim FastAPI's default 422 body (which echoes the
|
|
61
|
+
# submitted values and internal locator detail) to a field + message summary.
|
|
62
|
+
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
|
63
|
+
|
|
64
|
+
{% if request_limit_module_import %}
|
|
65
|
+
# Request-body size limit (defense-in-depth against large-body memory
|
|
66
|
+
# pressure). Added before CORS/CSRF so it sits closest to the route handlers
|
|
67
|
+
# and rejects oversized bodies with 413 before they are read into memory;
|
|
68
|
+
# CORS therefore stays the outermost middleware.
|
|
69
|
+
app.add_middleware(
|
|
70
|
+
RequestBodySizeLimitMiddleware, max_body_bytes={{ max_request_body_bytes }}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
{% endif %}
|
|
55
74
|
{% if rate_limit_module_import %}
|
|
56
75
|
# Rate limiting (slowapi) — wire the shared limiter onto app.state and
|
|
57
76
|
# register the 429 exception handler. Per-endpoint @limiter.limit(...)
|
|
@@ -67,14 +86,36 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
|
67
86
|
app.add_middleware(CsrfMiddleware)
|
|
68
87
|
|
|
69
88
|
{% endif %}
|
|
70
|
-
# CORS middleware
|
|
71
|
-
|
|
89
|
+
# CORS middleware.
|
|
90
|
+
#
|
|
91
|
+
# Set CORS_ORIGINS to a comma-separated allowlist per deployment, e.g.
|
|
92
|
+
# CORS_ORIGINS="https://app.example.com,https://admin.example.com"
|
|
93
|
+
# The default is a localhost dev origin; never ship a wildcard to production.
|
|
94
|
+
#
|
|
95
|
+
# Credentials are gated on the absence of a wildcard on purpose: pairing a
|
|
96
|
+
# wildcard origin with credentials makes Starlette reflect the caller's Origin
|
|
97
|
+
# and echo an Access-Control-Allow-Credentials header — the textbook CORS hole.
|
|
98
|
+
# Credentials therefore stay off unless an explicit allowlist is configured.
|
|
99
|
+
cors_origins = [
|
|
100
|
+
origin.strip()
|
|
101
|
+
for origin in os.getenv("CORS_ORIGINS", "http://localhost:3000").split(",")
|
|
102
|
+
if origin.strip()
|
|
103
|
+
]
|
|
72
104
|
app.add_middleware(
|
|
73
105
|
CORSMiddleware,
|
|
74
106
|
allow_origins=cors_origins,
|
|
75
|
-
allow_credentials=
|
|
76
|
-
|
|
77
|
-
|
|
107
|
+
allow_credentials="*" not in cors_origins,
|
|
108
|
+
# Narrowed to the methods the generated routes serve. FastAPI answers
|
|
109
|
+
# HEAD on every GET route, so HEAD is included to keep preflight happy.
|
|
110
|
+
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"],
|
|
111
|
+
# Narrowed to the headers the generated routes need; add others (e.g.
|
|
112
|
+
# "Authorization") if you layer on token auth.
|
|
113
|
+
allow_headers=[
|
|
114
|
+
"Content-Type",
|
|
115
|
+
{% if csrf_module_import %}
|
|
116
|
+
"X-CSRF-Token",
|
|
117
|
+
{% endif %}
|
|
118
|
+
],
|
|
78
119
|
)
|
|
79
120
|
|
|
80
121
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
{#-
|
|
2
|
+
Request Body Size-Limit Middleware Template
|
|
3
|
+
Pure-ASGI middleware that rejects oversized request bodies with 413.
|
|
4
|
+
-#}
|
|
5
|
+
{% from "_shared/_base.j2" import generated_marker %}
|
|
6
|
+
{{ generated_marker() }}
|
|
7
|
+
|
|
8
|
+
"""
|
|
9
|
+
Request body size-limit middleware.
|
|
10
|
+
|
|
11
|
+
Rejects HTTP requests whose body exceeds a configured maximum with a 413
|
|
12
|
+
response before the body is read into memory by route handlers. This is
|
|
13
|
+
defense-in-depth against large-body memory pressure / soft denial-of-service.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from collections import deque
|
|
18
|
+
|
|
19
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RequestBodySizeLimitMiddleware:
|
|
23
|
+
"""ASGI middleware that caps the size of incoming request bodies.
|
|
24
|
+
|
|
25
|
+
When the client declares a ``Content-Length`` larger than
|
|
26
|
+
``max_body_bytes`` the request is rejected immediately. For requests
|
|
27
|
+
without a ``Content-Length`` (e.g. chunked transfer-encoding) the body
|
|
28
|
+
is counted as it streams and rejected once it crosses the cap, so no
|
|
29
|
+
more than ``max_body_bytes`` is ever buffered.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, app: ASGIApp, max_body_bytes: int) -> None:
|
|
33
|
+
self.app = app
|
|
34
|
+
self.max_body_bytes = max_body_bytes
|
|
35
|
+
|
|
36
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
37
|
+
if scope["type"] != "http" or self.max_body_bytes <= 0:
|
|
38
|
+
await self.app(scope, receive, send)
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
content_length = _content_length(scope)
|
|
42
|
+
if content_length is not None:
|
|
43
|
+
if content_length > self.max_body_bytes:
|
|
44
|
+
await self._reject(send)
|
|
45
|
+
return
|
|
46
|
+
# Content-Length within the cap: stream through untouched.
|
|
47
|
+
await self.app(scope, receive, send)
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
# No Content-Length (e.g. chunked): buffer up to the cap, counting
|
|
51
|
+
# bytes as they arrive and rejecting before the limit is exceeded.
|
|
52
|
+
# A deque gives O(1) popleft on replay — a list's pop(0) is O(N), so
|
|
53
|
+
# replaying many small chunks would be O(N^2), a DoS footgun in the
|
|
54
|
+
# very middleware meant to blunt one.
|
|
55
|
+
buffered: deque[Message] = deque()
|
|
56
|
+
total = 0
|
|
57
|
+
while True:
|
|
58
|
+
message = await receive()
|
|
59
|
+
if message["type"] == "http.request":
|
|
60
|
+
total += len(message.get("body", b""))
|
|
61
|
+
if total > self.max_body_bytes:
|
|
62
|
+
await self._reject(send)
|
|
63
|
+
return
|
|
64
|
+
buffered.append(message)
|
|
65
|
+
if not message.get("more_body", False):
|
|
66
|
+
break
|
|
67
|
+
elif message["type"] == "http.disconnect":
|
|
68
|
+
# Client went away mid-body; nothing to forward downstream.
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
async def replay() -> Message:
|
|
72
|
+
if buffered:
|
|
73
|
+
return buffered.popleft()
|
|
74
|
+
return await receive()
|
|
75
|
+
|
|
76
|
+
await self.app(scope, replay, send)
|
|
77
|
+
|
|
78
|
+
async def _reject(self, send: Send) -> None:
|
|
79
|
+
"""Send a structured 413 response without invoking the app."""
|
|
80
|
+
body = json.dumps(
|
|
81
|
+
{
|
|
82
|
+
"error": "request_too_large",
|
|
83
|
+
"message": "Request body exceeds the maximum allowed size",
|
|
84
|
+
}
|
|
85
|
+
).encode("utf-8")
|
|
86
|
+
await send(
|
|
87
|
+
{
|
|
88
|
+
"type": "http.response.start",
|
|
89
|
+
"status": 413,
|
|
90
|
+
"headers": [
|
|
91
|
+
(b"content-type", b"application/json"),
|
|
92
|
+
(b"content-length", str(len(body)).encode("latin-1")),
|
|
93
|
+
],
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
await send({"type": "http.response.body", "body": body})
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _content_length(scope: Scope) -> int | None:
|
|
100
|
+
"""Return the request's Content-Length as an int, or None if absent/invalid.
|
|
101
|
+
|
|
102
|
+
A declared length is treated as invalid (returns None) — forcing the caller
|
|
103
|
+
onto the chunked byte-counting path, which is safe regardless of any header
|
|
104
|
+
— when it is negative or when *more than one* Content-Length header is
|
|
105
|
+
present. Multiple Content-Length headers are a request-smuggling signal: a
|
|
106
|
+
downstream server or proxy might honor a different one than this middleware,
|
|
107
|
+
so an oversized request could slip past a guard keyed on the first value.
|
|
108
|
+
Compliant servers reject both cases at the protocol layer, but the
|
|
109
|
+
middleware must not rely on that pre-filtering — defense-in-depth.
|
|
110
|
+
"""
|
|
111
|
+
found: int | None = None
|
|
112
|
+
for name, value in scope.get("headers", []):
|
|
113
|
+
if name == b"content-length":
|
|
114
|
+
if found is not None:
|
|
115
|
+
# Duplicate Content-Length headers: force the chunked path.
|
|
116
|
+
return None
|
|
117
|
+
try:
|
|
118
|
+
n = int(value)
|
|
119
|
+
except ValueError:
|
|
120
|
+
return None
|
|
121
|
+
if n < 0:
|
|
122
|
+
return None
|
|
123
|
+
found = n
|
|
124
|
+
return found
|
{model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator_kit.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: model-generator-kit
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: One-shot bootstrap generator for database models, API models, routes, and tests
|
|
5
5
|
Author-email: nuncaeslupus <imarcos@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -140,4 +140,4 @@ Define specifications once in JSON. Generate production-ready scaffolds. Then ma
|
|
|
140
140
|
|
|
141
141
|
---
|
|
142
142
|
|
|
143
|
-
**Model Generator** | Bootstrap Tool for API Backends | v0.1.
|
|
143
|
+
**Model Generator** | Bootstrap Tool for API Backends | v0.1.2
|
{model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator_kit.egg-info/SOURCES.txt
RENAMED
|
@@ -40,6 +40,7 @@ src/model_generator/stacks/python-fastapi/templates/infrastructure/gitignore.j2
|
|
|
40
40
|
src/model_generator/stacks/python-fastapi/templates/infrastructure/main.py.j2
|
|
41
41
|
src/model_generator/stacks/python-fastapi/templates/infrastructure/pyproject.toml.j2
|
|
42
42
|
src/model_generator/stacks/python-fastapi/templates/infrastructure/rate_limit.py.j2
|
|
43
|
+
src/model_generator/stacks/python-fastapi/templates/infrastructure/request_limit.py.j2
|
|
43
44
|
src/model_generator/stacks/python-fastapi/templates/infrastructure/types.py.j2
|
|
44
45
|
src/model_generator/stacks/python-fastapi/templates/infrastructure/utils.py.j2
|
|
45
46
|
src/model_generator/stacks/python-fastapi/templates/infrastructure/validators.py.j2
|