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.
Files changed (86) hide show
  1. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/PKG-INFO +2 -2
  2. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/README.md +1 -1
  3. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/pyproject.toml +1 -1
  4. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/__init__.py +1 -1
  5. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/generators/infrastructure.py +80 -1
  6. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/config.yaml +12 -0
  7. {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
  8. {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
  9. {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
  10. model_generator_kit-0.1.2/src/model_generator/stacks/python-fastapi/templates/infrastructure/request_limit.py.j2 +124 -0
  11. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator_kit.egg-info/PKG-INFO +2 -2
  12. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator_kit.egg-info/SOURCES.txt +1 -0
  13. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_generators.py +508 -0
  14. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/LICENSE +0 -0
  15. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/setup.cfg +0 -0
  16. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/generate.py +0 -0
  17. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/generators/__init__.py +0 -0
  18. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/generators/api.py +0 -0
  19. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/generators/constraints.py +0 -0
  20. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/generators/database.py +0 -0
  21. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/generators/enums.py +0 -0
  22. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/generators/migrations.py +0 -0
  23. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/py.typed +0 -0
  24. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/schema/model.schema.json +0 -0
  25. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/_shared/_base.j2 +0 -0
  26. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/_shared/_entity.j2 +0 -0
  27. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/_shared/_examples.j2 +0 -0
  28. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/_shared/_fields.j2 +0 -0
  29. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/_shared/_tests.j2 +0 -0
  30. {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
  31. {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
  32. {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
  33. {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
  34. {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
  35. {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
  36. {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
  37. {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
  38. {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
  39. {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
  40. {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
  41. {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
  42. {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
  43. {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
  44. {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
  45. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/gitignore.j2 +0 -0
  46. {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
  47. {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
  48. {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
  49. {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
  50. {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
  51. {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
  52. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/migrations/ini.j2 +0 -0
  53. {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
  54. {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
  55. {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
  56. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/utils/__init__.py +0 -0
  57. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/utils/conftest_generator.py +0 -0
  58. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/utils/constants.py +0 -0
  59. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/utils/loaders.py +0 -0
  60. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/utils/parser.py +0 -0
  61. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/utils/quality.py +0 -0
  62. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/utils/templates.py +0 -0
  63. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/validate.py +0 -0
  64. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/wizard/__init__.py +0 -0
  65. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/wizard/actions/__init__.py +0 -0
  66. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/wizard/actions/clean.py +0 -0
  67. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/wizard/actions/generate.py +0 -0
  68. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/wizard/actions/project_setup.py +0 -0
  69. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/wizard/actions/test_runner.py +0 -0
  70. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/wizard/menu.py +0 -0
  71. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator/wizard/prompts.py +0 -0
  72. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator_kit.egg-info/dependency_links.txt +0 -0
  73. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator_kit.egg-info/entry_points.txt +0 -0
  74. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator_kit.egg-info/requires.txt +0 -0
  75. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/src/model_generator_kit.egg-info/top_level.txt +0 -0
  76. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_cleanup.py +0 -0
  77. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_cli.py +0 -0
  78. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_edge_cases.py +0 -0
  79. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_enum_examples.py +0 -0
  80. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_full_generation.py +0 -0
  81. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_integration.py +0 -0
  82. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_template_utils.py +0 -0
  83. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_utils.py +0 -0
  84. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_validate.py +0 -0
  85. {model_generator_kit-0.1.0 → model_generator_kit-0.1.2}/tests/test_validation.py +0 -0
  86. {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.0
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.0
143
+ **Model Generator** | Bootstrap Tool for API Backends | v0.1.2
@@ -104,4 +104,4 @@ Define specifications once in JSON. Generate production-ready scaffolds. Then ma
104
104
 
105
105
  ---
106
106
 
107
- **Model Generator** | Bootstrap Tool for API Backends | v0.1.0
107
+ **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.0"
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"}
@@ -3,4 +3,4 @@
3
3
  # Single source of truth is pyproject.toml's [project].version. `make
4
4
  # version-sync` propagates that value here; `make check-version-sync` (run in
5
5
  # CI) fails the build if they drift. Do not edit by hand.
6
- __version__ = "0.1.0"
6
+ __version__ = "0.1.2"
@@ -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: str | None = Query(None, description="Filter {{ field_name }} after this datetime (ISO 8601)"),
183
- {{ field_name }}_before: str | None = Query(None, description="Filter {{ field_name }} before this datetime (ISO 8601)"),
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: str | None = Query(None, description="Filter {{ field_name }} minimum value"),
186
- {{ field_name }}_max: str | None = Query(None, description="Filter {{ field_name }} maximum value"),
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
- dt_after = datetime.fromisoformat({{ field_name }}_after.replace('Z', '+00:00'))
229
- stmt = stmt.where({{ entity_name }}.{{ field_name }} >= dt_after)
230
- count_stmt = count_stmt.where({{ entity_name }}.{{ field_name }} >= dt_after)
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
- dt_before = datetime.fromisoformat({{ field_name }}_before.replace('Z', '+00:00'))
233
- stmt = stmt.where({{ entity_name }}.{{ field_name }} <= dt_before)
234
- count_stmt = count_stmt.where({{ entity_name }}.{{ field_name }} <= dt_before)
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 }} >= Decimal({{ field_name }}_min))
238
- count_stmt = count_stmt.where({{ entity_name }}.{{ field_name }} >= Decimal({{ field_name }}_min))
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 }} <= Decimal({{ field_name }}_max))
241
- count_stmt = count_stmt.where({{ entity_name }}.{{ field_name }} <= Decimal({{ field_name }}_max))
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 fastapi import HTTPException, status
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
- # Extract field name if possible
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 - set CORS_ORIGINS env var for production (comma-separated)
71
- cors_origins = os.getenv("CORS_ORIGINS", "*").split(",")
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=True,
76
- allow_methods=["*"],
77
- allow_headers=["*"],
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: model-generator-kit
3
- Version: 0.1.0
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.0
143
+ **Model Generator** | Bootstrap Tool for API Backends | v0.1.2
@@ -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