python-flashapi 0.2.0__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flashapi/adapters/django.py +72 -1
- flashapi/adapters/fastapi.py +47 -1
- flashapi/adapters/flask.py +49 -1
- flashapi/core/custom_routes.py +24 -2
- flashapi/core/schema.py +4 -1
- flashapi/core/visibility.py +36 -0
- flashapi/features/cache.py +229 -0
- flashapi/features/health.py +69 -0
- flashapi/features/idempotency.py +375 -0
- flashapi/storage/orm.py +3 -0
- {python_flashapi-0.2.0.dist-info → python_flashapi-0.4.0.dist-info}/METADATA +21 -5
- {python_flashapi-0.2.0.dist-info → python_flashapi-0.4.0.dist-info}/RECORD +15 -12
- {python_flashapi-0.2.0.dist-info → python_flashapi-0.4.0.dist-info}/WHEEL +1 -1
- {python_flashapi-0.2.0.dist-info → python_flashapi-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {python_flashapi-0.2.0.dist-info → python_flashapi-0.4.0.dist-info}/licenses/NOTICE +0 -0
flashapi/adapters/django.py
CHANGED
|
@@ -9,9 +9,10 @@ from flashapi.core.custom_routes import (
|
|
|
9
9
|
)
|
|
10
10
|
from flashapi.core.response import create_error_response, create_item_response, create_list_response
|
|
11
11
|
from flashapi.core.schema import Model, ModelSchema
|
|
12
|
-
from flashapi.core.visibility import export_fields, filter_response, writable_fields
|
|
12
|
+
from flashapi.core.visibility import export_fields, filter_response, select_fields, writable_fields
|
|
13
13
|
from flashapi.docs.openapi import generate_openapi_schema, get_swagger_html
|
|
14
14
|
from flashapi.features import apply_filters, apply_search, apply_sorting, paginate
|
|
15
|
+
from flashapi.features.health import get_health_check
|
|
15
16
|
from flashapi.inspectors import inspect_model
|
|
16
17
|
from flashapi.storage.orm import DjangoORMStorage
|
|
17
18
|
|
|
@@ -77,6 +78,7 @@ def generate_urls(
|
|
|
77
78
|
schema.scope = wrapper.scope
|
|
78
79
|
schema.tenant_field = wrapper.tenant_field
|
|
79
80
|
schema.owner_field = wrapper.owner_field
|
|
81
|
+
schema.current_user_field = wrapper.current_user_field
|
|
80
82
|
|
|
81
83
|
from flashapi.core.schema import validate_soft_delete
|
|
82
84
|
validate_soft_delete(wrapper.model_class, wrapper.soft_delete)
|
|
@@ -113,6 +115,7 @@ def generate_urls(
|
|
|
113
115
|
))
|
|
114
116
|
|
|
115
117
|
urlpatterns.extend(_create_api_root_view(all_schemas, docs))
|
|
118
|
+
urlpatterns.extend(_create_health_views())
|
|
116
119
|
|
|
117
120
|
return urlpatterns
|
|
118
121
|
|
|
@@ -264,6 +267,7 @@ def _create_django_views(
|
|
|
264
267
|
model_scope = schema.scope
|
|
265
268
|
model_tenant_field = schema.tenant_field
|
|
266
269
|
model_owner_field = schema.owner_field
|
|
270
|
+
model_current_user_field = schema.current_user_field
|
|
267
271
|
patterns = []
|
|
268
272
|
|
|
269
273
|
def _check_auth(request, operation):
|
|
@@ -333,6 +337,7 @@ def _create_django_views(
|
|
|
333
337
|
sort = params.get("sort")
|
|
334
338
|
search = params.get("search")
|
|
335
339
|
deleted_param = params.get("deleted", "false").lower() == "true"
|
|
340
|
+
fields_param = params.get("fields") # Field selection: ?fields=id,name,price
|
|
336
341
|
|
|
337
342
|
only_deleted = deleted_param and supports_soft_delete
|
|
338
343
|
items = storage.list_all(_table, only_deleted=only_deleted)
|
|
@@ -348,6 +353,12 @@ def _create_django_views(
|
|
|
348
353
|
items = apply_sorting(items, sort, _fields)
|
|
349
354
|
page_items, total = paginate(items, page, size)
|
|
350
355
|
page_items = [filter_response(item, _schema) for item in page_items]
|
|
356
|
+
|
|
357
|
+
# Apply field selection if ?fields parameter provided
|
|
358
|
+
if fields_param:
|
|
359
|
+
field_list = [f.strip() for f in fields_param.split(",")]
|
|
360
|
+
page_items = [select_fields(item, field_list, _schema) for item in page_items]
|
|
361
|
+
|
|
351
362
|
if metrics:
|
|
352
363
|
metrics.record("READ", entity_name)
|
|
353
364
|
return JsonResponse(
|
|
@@ -369,6 +380,24 @@ def _create_django_views(
|
|
|
369
380
|
if scope_filter:
|
|
370
381
|
data.update(scope_filter)
|
|
371
382
|
|
|
383
|
+
# Auto-inject current user if current_user_field specified
|
|
384
|
+
if model_current_user_field and user and auth_backend:
|
|
385
|
+
user_id = auth_backend.get_owner_id(user)
|
|
386
|
+
if user_id is not None:
|
|
387
|
+
# For ForeignKey fields, Django expects field_id
|
|
388
|
+
# For non-FK fields, use field directly
|
|
389
|
+
# Try FK syntax first (most common case)
|
|
390
|
+
try:
|
|
391
|
+
storage._model._meta.get_field(model_current_user_field)
|
|
392
|
+
field = storage._model._meta.get_field(model_current_user_field)
|
|
393
|
+
if field.many_to_one: # ForeignKey
|
|
394
|
+
data[f"{model_current_user_field}_id"] = user_id
|
|
395
|
+
else:
|
|
396
|
+
data[model_current_user_field] = user_id
|
|
397
|
+
except Exception:
|
|
398
|
+
# Fallback: assume FK
|
|
399
|
+
data[f"{model_current_user_field}_id"] = user_id
|
|
400
|
+
|
|
372
401
|
item = storage.create(_table, data)
|
|
373
402
|
if metrics:
|
|
374
403
|
metrics.record("CREATE", entity_name, str(item.get("id", "")))
|
|
@@ -659,6 +688,13 @@ def _create_django_views(
|
|
|
659
688
|
return JsonResponse(create_error_response("Not found", 404), status=404)
|
|
660
689
|
|
|
661
690
|
item = filter_response(item, _schema)
|
|
691
|
+
|
|
692
|
+
# Apply field selection if ?fields parameter provided
|
|
693
|
+
fields_param = request.GET.get("fields")
|
|
694
|
+
if fields_param:
|
|
695
|
+
field_list = [f.strip() for f in fields_param.split(",")]
|
|
696
|
+
item = select_fields(item, field_list, _schema)
|
|
697
|
+
|
|
662
698
|
return JsonResponse(create_item_response(item, formatter))
|
|
663
699
|
|
|
664
700
|
if request.method == "PUT" and "update" in _schema.permissions:
|
|
@@ -769,3 +805,38 @@ def _create_django_views(
|
|
|
769
805
|
patterns.append(path(f"{table}/<str:item_id>/history/", history_view, name=f"{table}_history"))
|
|
770
806
|
|
|
771
807
|
return patterns
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _create_health_views():
|
|
811
|
+
"""Create health check views for production monitoring."""
|
|
812
|
+
from django.http import JsonResponse
|
|
813
|
+
from django.urls import path
|
|
814
|
+
|
|
815
|
+
health_check = get_health_check()
|
|
816
|
+
|
|
817
|
+
def liveness_view(request):
|
|
818
|
+
"""Liveness probe — is the application running?"""
|
|
819
|
+
return JsonResponse(health_check.liveness())
|
|
820
|
+
|
|
821
|
+
def readiness_view(request):
|
|
822
|
+
"""Readiness probe — is the application ready to serve traffic?"""
|
|
823
|
+
data, status_code = health_check.readiness()
|
|
824
|
+
return JsonResponse(data, status=status_code)
|
|
825
|
+
|
|
826
|
+
# Register database check for Django ORM
|
|
827
|
+
def check_database():
|
|
828
|
+
try:
|
|
829
|
+
from django.db import connection
|
|
830
|
+
with connection.cursor() as cursor:
|
|
831
|
+
cursor.execute("SELECT 1")
|
|
832
|
+
return True
|
|
833
|
+
except Exception:
|
|
834
|
+
return False
|
|
835
|
+
|
|
836
|
+
health_check.register_check("database", check_database)
|
|
837
|
+
health_check.mark_ready()
|
|
838
|
+
|
|
839
|
+
return [
|
|
840
|
+
path("health/", liveness_view, name="health_liveness"),
|
|
841
|
+
path("ready/", readiness_view, name="health_readiness"),
|
|
842
|
+
]
|
flashapi/adapters/fastapi.py
CHANGED
|
@@ -10,10 +10,11 @@ from pydantic import BaseModel, create_model
|
|
|
10
10
|
from flashapi.core.relations import find_expandable_fields, resolve_relations
|
|
11
11
|
from flashapi.core.response import create_error_response, create_item_response, create_list_response
|
|
12
12
|
from flashapi.core.schema import FieldType, Model, ModelSchema
|
|
13
|
-
from flashapi.core.visibility import export_fields, filter_response, writable_fields
|
|
13
|
+
from flashapi.core.visibility import export_fields, filter_response, select_fields, writable_fields
|
|
14
14
|
from flashapi.features import apply_filters, apply_search, apply_sorting, paginate
|
|
15
15
|
from flashapi.features.dashboard import DASHBOARD_HTML, MetricsCollector
|
|
16
16
|
from flashapi.features.export import CONTENT_TYPES, EXPORTERS
|
|
17
|
+
from flashapi.features.health import get_health_check
|
|
17
18
|
from flashapi.inspectors import inspect_model
|
|
18
19
|
from flashapi.storage.auto import AutoStorage
|
|
19
20
|
from flashapi.storage.sqlalchemy import SQLAlchemyStorage
|
|
@@ -143,6 +144,7 @@ class FlashAPI:
|
|
|
143
144
|
self._add_dashboard_routes()
|
|
144
145
|
self._add_websocket_route()
|
|
145
146
|
self._add_api_root()
|
|
147
|
+
self._add_health_routes()
|
|
146
148
|
|
|
147
149
|
def _prepare_model(self, model_entry) -> None:
|
|
148
150
|
wrapper = model_entry if isinstance(model_entry, Model) else Model(model_entry)
|
|
@@ -248,6 +250,36 @@ class FlashAPI:
|
|
|
248
250
|
}
|
|
249
251
|
return {"resources": resources, "links": links}
|
|
250
252
|
|
|
253
|
+
def _add_health_routes(self) -> None:
|
|
254
|
+
"""Add health check endpoints for production monitoring."""
|
|
255
|
+
health_check = get_health_check()
|
|
256
|
+
|
|
257
|
+
@self._app.get("/health", tags=["Health"], include_in_schema=False)
|
|
258
|
+
async def liveness():
|
|
259
|
+
"""Liveness probe — is the application running?"""
|
|
260
|
+
return health_check.liveness()
|
|
261
|
+
|
|
262
|
+
@self._app.get("/ready", tags=["Health"], include_in_schema=False)
|
|
263
|
+
async def readiness():
|
|
264
|
+
"""Readiness probe — is the application ready to serve traffic?"""
|
|
265
|
+
data, status_code = health_check.readiness()
|
|
266
|
+
return JSONResponse(content=data, status_code=status_code)
|
|
267
|
+
|
|
268
|
+
# Register database check if using SQLAlchemy
|
|
269
|
+
if self._session_factory:
|
|
270
|
+
def check_database():
|
|
271
|
+
try:
|
|
272
|
+
session = self._session_factory()
|
|
273
|
+
session.execute("SELECT 1")
|
|
274
|
+
session.close()
|
|
275
|
+
return True
|
|
276
|
+
except Exception:
|
|
277
|
+
return False
|
|
278
|
+
health_check.register_check("database", check_database)
|
|
279
|
+
|
|
280
|
+
# Mark ready after all routes are registered
|
|
281
|
+
health_check.mark_ready()
|
|
282
|
+
|
|
251
283
|
def _add_rate_limit_middleware(self) -> None:
|
|
252
284
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
253
285
|
|
|
@@ -418,6 +450,13 @@ class FlashAPI:
|
|
|
418
450
|
|
|
419
451
|
metrics.record("READ", tag)
|
|
420
452
|
page_items = [filter_response(item, model_schema) for item in page_items]
|
|
453
|
+
|
|
454
|
+
# Apply field selection if ?fields parameter provided
|
|
455
|
+
fields_param = request.query_params.get("fields")
|
|
456
|
+
if fields_param:
|
|
457
|
+
field_list = [f.strip() for f in fields_param.split(",")]
|
|
458
|
+
page_items = [select_fields(item, field_list, model_schema) for item in page_items]
|
|
459
|
+
|
|
421
460
|
return create_list_response(page_items, total, page, size, formatter)
|
|
422
461
|
|
|
423
462
|
def _add_read_route(self, table, formatter, storage, tag, expandable, model_schema, lookup_field="id") -> None:
|
|
@@ -449,6 +488,13 @@ class FlashAPI:
|
|
|
449
488
|
item = self._expand_items([item], expand, expandable)[0]
|
|
450
489
|
|
|
451
490
|
item = filter_response(item, model_schema)
|
|
491
|
+
|
|
492
|
+
# Apply field selection if ?fields parameter provided
|
|
493
|
+
fields_param = request.query_params.get("fields")
|
|
494
|
+
if fields_param:
|
|
495
|
+
field_list = [f.strip() for f in fields_param.split(",")]
|
|
496
|
+
item = select_fields(item, field_list, model_schema)
|
|
497
|
+
|
|
452
498
|
return create_item_response(item, formatter)
|
|
453
499
|
|
|
454
500
|
def _add_history_route(self, table, entity_name, lookup_field="id", model_schema=None) -> None:
|
flashapi/adapters/flask.py
CHANGED
|
@@ -10,9 +10,10 @@ from flashapi.core.custom_routes import (
|
|
|
10
10
|
from flashapi.core.relations import find_expandable_fields, resolve_relations
|
|
11
11
|
from flashapi.core.response import create_error_response, create_item_response, create_list_response
|
|
12
12
|
from flashapi.core.schema import Model, ModelSchema
|
|
13
|
-
from flashapi.core.visibility import export_fields, filter_response, writable_fields
|
|
13
|
+
from flashapi.core.visibility import export_fields, filter_response, select_fields, writable_fields
|
|
14
14
|
from flashapi.docs.openapi import generate_openapi_schema, get_swagger_html
|
|
15
15
|
from flashapi.features import apply_filters, apply_search, apply_sorting, paginate
|
|
16
|
+
from flashapi.features.health import get_health_check
|
|
16
17
|
from flashapi.inspectors import inspect_model
|
|
17
18
|
from flashapi.storage.auto import AutoStorage
|
|
18
19
|
from flashapi.storage.sqlalchemy import SQLAlchemyStorage
|
|
@@ -138,6 +139,7 @@ def register_models(
|
|
|
138
139
|
_add_docs_routes(blueprint, all_schemas, custom_routes or [], flask_app=app)
|
|
139
140
|
|
|
140
141
|
_add_api_root_route(blueprint, all_schemas, base_path, docs)
|
|
142
|
+
_add_health_routes(app, session_factory)
|
|
141
143
|
|
|
142
144
|
app.register_blueprint(blueprint)
|
|
143
145
|
|
|
@@ -439,6 +441,13 @@ def _create_flask_routes(
|
|
|
439
441
|
if _metrics:
|
|
440
442
|
_metrics.record("READ", entity_name)
|
|
441
443
|
page_items = [filter_response(item, _schema) for item in page_items]
|
|
444
|
+
|
|
445
|
+
# Apply field selection if ?fields parameter provided
|
|
446
|
+
fields_param = request.args.get("fields")
|
|
447
|
+
if fields_param:
|
|
448
|
+
field_list = [f.strip() for f in fields_param.split(",")]
|
|
449
|
+
page_items = [select_fields(item, field_list, _schema) for item in page_items]
|
|
450
|
+
|
|
442
451
|
return jsonify(create_list_response(page_items, total, page, size, formatter))
|
|
443
452
|
|
|
444
453
|
if "read" in schema.permissions:
|
|
@@ -461,6 +470,13 @@ def _create_flask_routes(
|
|
|
461
470
|
item = _expand_items([item], expand, _exp, storage)[0]
|
|
462
471
|
|
|
463
472
|
item = filter_response(item, _schema)
|
|
473
|
+
|
|
474
|
+
# Apply field selection if ?fields parameter provided
|
|
475
|
+
fields_param = request.args.get("fields")
|
|
476
|
+
if fields_param:
|
|
477
|
+
field_list = [f.strip() for f in fields_param.split(",")]
|
|
478
|
+
item = select_fields(item, field_list, _schema)
|
|
479
|
+
|
|
464
480
|
return jsonify(create_item_response(item, formatter))
|
|
465
481
|
|
|
466
482
|
if "read" in schema.permissions and entity_audit:
|
|
@@ -726,3 +742,35 @@ def _create_flask_routes(
|
|
|
726
742
|
mimetype=CONTENT_TYPES[fmt],
|
|
727
743
|
headers={"Content-Disposition": f'attachment; filename="{_table}.{fmt}"'},
|
|
728
744
|
)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _add_health_routes(app, session_factory) -> None:
|
|
748
|
+
"""Add health check endpoints for production monitoring."""
|
|
749
|
+
from flask import jsonify
|
|
750
|
+
health_check = get_health_check()
|
|
751
|
+
|
|
752
|
+
@app.route("/health")
|
|
753
|
+
def liveness():
|
|
754
|
+
"""Liveness probe — is the application running?"""
|
|
755
|
+
return jsonify(health_check.liveness())
|
|
756
|
+
|
|
757
|
+
@app.route("/ready")
|
|
758
|
+
def readiness():
|
|
759
|
+
"""Readiness probe — is the application ready to serve traffic?"""
|
|
760
|
+
data, status_code = health_check.readiness()
|
|
761
|
+
return jsonify(data), status_code
|
|
762
|
+
|
|
763
|
+
# Register database check if using SQLAlchemy
|
|
764
|
+
if session_factory:
|
|
765
|
+
def check_database():
|
|
766
|
+
try:
|
|
767
|
+
session = session_factory()
|
|
768
|
+
session.execute("SELECT 1")
|
|
769
|
+
session.close()
|
|
770
|
+
return True
|
|
771
|
+
except Exception:
|
|
772
|
+
return False
|
|
773
|
+
health_check.register_check("database", check_database)
|
|
774
|
+
|
|
775
|
+
# Mark ready after all routes are registered
|
|
776
|
+
health_check.mark_ready()
|
flashapi/core/custom_routes.py
CHANGED
|
@@ -121,6 +121,8 @@ def discover_django_views(url_patterns, trailing_slash: bool = True) -> dict[str
|
|
|
121
121
|
"""Scan Django URL patterns for @api_doc-decorated views and build OpenAPI paths."""
|
|
122
122
|
paths: dict[str, dict] = {}
|
|
123
123
|
|
|
124
|
+
import re
|
|
125
|
+
|
|
124
126
|
for pattern in url_patterns:
|
|
125
127
|
callback = getattr(pattern, "callback", None)
|
|
126
128
|
if callback is None:
|
|
@@ -130,8 +132,9 @@ def discover_django_views(url_patterns, trailing_slash: bool = True) -> dict[str
|
|
|
130
132
|
if doc is None:
|
|
131
133
|
continue
|
|
132
134
|
|
|
133
|
-
# Build path from Django pattern
|
|
135
|
+
# Build path from Django pattern, converting <type:name> to {name}
|
|
134
136
|
path_str = "/" + str(pattern.pattern)
|
|
137
|
+
path_str = re.sub(r"<(?:\w+:)?(\w+)>", r"{\1}", path_str)
|
|
135
138
|
if trailing_slash and not path_str.endswith("/"):
|
|
136
139
|
path_str += "/"
|
|
137
140
|
|
|
@@ -143,8 +146,27 @@ def discover_django_views(url_patterns, trailing_slash: bool = True) -> dict[str
|
|
|
143
146
|
if path_str not in paths:
|
|
144
147
|
paths[path_str] = {}
|
|
145
148
|
|
|
149
|
+
# Extract path parameters from URL pattern
|
|
150
|
+
path_params = re.findall(r"\{(\w+)\}", path_str)
|
|
151
|
+
|
|
146
152
|
for method in methods:
|
|
147
|
-
|
|
153
|
+
operation = _build_openapi_operation(doc, method)
|
|
154
|
+
if path_params:
|
|
155
|
+
if "parameters" not in operation:
|
|
156
|
+
operation["parameters"] = []
|
|
157
|
+
# Remove any query params that are actually path params
|
|
158
|
+
operation["parameters"] = [
|
|
159
|
+
p for p in operation["parameters"] if p["name"] not in path_params
|
|
160
|
+
]
|
|
161
|
+
for pname in path_params:
|
|
162
|
+
ptype = doc.get("params", {}).get(pname, "string") if doc.get("params") else "string"
|
|
163
|
+
operation["parameters"].append({
|
|
164
|
+
"name": pname,
|
|
165
|
+
"in": "path",
|
|
166
|
+
"required": True,
|
|
167
|
+
"schema": TYPE_MAP.get(ptype, {"type": "string"}),
|
|
168
|
+
})
|
|
169
|
+
paths[path_str][method] = operation
|
|
148
170
|
|
|
149
171
|
return paths
|
|
150
172
|
|
flashapi/core/schema.py
CHANGED
|
@@ -59,6 +59,7 @@ class ModelSchema:
|
|
|
59
59
|
scope: str | None = None # "tenant", "owner", or "both"
|
|
60
60
|
tenant_field: str | None = None
|
|
61
61
|
owner_field: str | None = None
|
|
62
|
+
current_user_field: str | None = None # Auto-inject authenticated user on CREATE
|
|
62
63
|
|
|
63
64
|
|
|
64
65
|
ALL_OPERATIONS = ["list", "read", "create", "update", "delete"]
|
|
@@ -112,12 +113,13 @@ class Model:
|
|
|
112
113
|
only: list[str] | None = None,
|
|
113
114
|
plural: str | None = None,
|
|
114
115
|
soft_delete: bool = False,
|
|
115
|
-
audit: bool =
|
|
116
|
+
audit: bool = True, # Enabled by default for full traceability
|
|
116
117
|
lookup_field: str = "id",
|
|
117
118
|
access: str | dict | bool | None = None,
|
|
118
119
|
scope: str | None = None,
|
|
119
120
|
tenant_field: str | None = None,
|
|
120
121
|
owner_field: str | None = None,
|
|
122
|
+
current_user_field: str | None = None,
|
|
121
123
|
) -> None:
|
|
122
124
|
self.model_class = model_class
|
|
123
125
|
self.plural = plural
|
|
@@ -128,6 +130,7 @@ class Model:
|
|
|
128
130
|
self.scope = scope
|
|
129
131
|
self.tenant_field = tenant_field
|
|
130
132
|
self.owner_field = owner_field
|
|
133
|
+
self.current_user_field = current_user_field
|
|
131
134
|
self.permissions = self._resolve_permissions(readonly, exclude, only)
|
|
132
135
|
|
|
133
136
|
def _resolve_permissions(
|
flashapi/core/visibility.py
CHANGED
|
@@ -44,3 +44,39 @@ def filter_input(data: dict, schema: ModelSchema) -> dict:
|
|
|
44
44
|
"""Remove readonly/hidden fields from input dict."""
|
|
45
45
|
allowed = writable_fields(schema)
|
|
46
46
|
return {k: v for k, v in data.items() if k in allowed}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def select_fields(data: dict, fields: list[str] | None, schema: ModelSchema) -> dict:
|
|
50
|
+
"""
|
|
51
|
+
Select only requested fields from response data.
|
|
52
|
+
|
|
53
|
+
Field selection (?fields=id,name,price) allows clients to request only specific fields,
|
|
54
|
+
reducing payload size and improving performance.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
data: Response dict to filter
|
|
58
|
+
fields: List of field names to include (None = return all visible fields)
|
|
59
|
+
schema: Model schema for validation
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Dict containing only requested fields that exist and are visible (id always included)
|
|
63
|
+
|
|
64
|
+
Example:
|
|
65
|
+
>>> select_fields({"id": 1, "name": "Laptop", "price": 999, "stock": 10}, ["name"], schema)
|
|
66
|
+
{"id": 1, "name": "Laptop"} # id always included
|
|
67
|
+
"""
|
|
68
|
+
if not fields:
|
|
69
|
+
return data
|
|
70
|
+
|
|
71
|
+
# Get visible fields from schema
|
|
72
|
+
visible = response_fields(schema)
|
|
73
|
+
|
|
74
|
+
# Filter: requested fields + always include id
|
|
75
|
+
result = {}
|
|
76
|
+
for k, v in data.items():
|
|
77
|
+
if k == "id":
|
|
78
|
+
result[k] = v # Always include id
|
|
79
|
+
elif k in fields and k in visible:
|
|
80
|
+
result[k] = v
|
|
81
|
+
|
|
82
|
+
return result
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cache layer for FlashAPI with graceful fallback.
|
|
3
|
+
|
|
4
|
+
Prevents Bug #4 (cache failure crash) by catching exceptions and continuing without cache.
|
|
5
|
+
Supports Redis, Memcached, and in-memory stores.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CacheBackend(ABC):
|
|
17
|
+
"""Abstract cache interface."""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def get(self, key: str) -> Any | None:
|
|
21
|
+
"""Get value from cache. Returns None if not found or error."""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def set(self, key: str, value: Any, ttl: int = 300) -> bool:
|
|
26
|
+
"""Set value in cache with TTL (seconds). Returns success."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def delete(self, key: str) -> bool:
|
|
31
|
+
"""Delete key from cache. Returns success."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def clear(self) -> bool:
|
|
36
|
+
"""Clear all cache. Returns success."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class InMemoryCache(CacheBackend):
|
|
41
|
+
"""In-memory cache (for development/testing)."""
|
|
42
|
+
|
|
43
|
+
def __init__(self):
|
|
44
|
+
self._store: dict[str, tuple[Any, float]] = {} # {key: (value, expire_at)}
|
|
45
|
+
|
|
46
|
+
def get(self, key: str) -> Any | None:
|
|
47
|
+
if key not in self._store:
|
|
48
|
+
return None
|
|
49
|
+
value, expire_at = self._store[key]
|
|
50
|
+
if time.time() > expire_at:
|
|
51
|
+
del self._store[key]
|
|
52
|
+
return None
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
def set(self, key: str, value: Any, ttl: int = 300) -> bool:
|
|
56
|
+
self._store[key] = (value, time.time() + ttl)
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
def delete(self, key: str) -> bool:
|
|
60
|
+
if key in self._store:
|
|
61
|
+
del self._store[key]
|
|
62
|
+
return True
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
def clear(self) -> bool:
|
|
66
|
+
self._store.clear()
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class RedisCache(CacheBackend):
|
|
71
|
+
"""Redis cache with graceful fallback."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, host: str = "localhost", port: int = 6379, db: int = 0):
|
|
74
|
+
try:
|
|
75
|
+
import redis
|
|
76
|
+
self._redis = redis.Redis(host=host, port=port, db=db, decode_responses=True)
|
|
77
|
+
self._redis.ping() # Test connection
|
|
78
|
+
self._available = True
|
|
79
|
+
except Exception:
|
|
80
|
+
self._redis = None
|
|
81
|
+
self._available = False
|
|
82
|
+
|
|
83
|
+
def get(self, key: str) -> Any | None:
|
|
84
|
+
if not self._available:
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
value = self._redis.get(key)
|
|
88
|
+
if value is None:
|
|
89
|
+
return None
|
|
90
|
+
return json.loads(value)
|
|
91
|
+
except Exception:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
def set(self, key: str, value: Any, ttl: int = 300) -> bool:
|
|
95
|
+
if not self._available:
|
|
96
|
+
return False
|
|
97
|
+
try:
|
|
98
|
+
self._redis.setex(key, ttl, json.dumps(value))
|
|
99
|
+
return True
|
|
100
|
+
except Exception:
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
def delete(self, key: str) -> bool:
|
|
104
|
+
if not self._available:
|
|
105
|
+
return False
|
|
106
|
+
try:
|
|
107
|
+
self._redis.delete(key)
|
|
108
|
+
return True
|
|
109
|
+
except Exception:
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
def clear(self) -> bool:
|
|
113
|
+
if not self._available:
|
|
114
|
+
return False
|
|
115
|
+
try:
|
|
116
|
+
self._redis.flushdb()
|
|
117
|
+
return True
|
|
118
|
+
except Exception:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class CacheLayer:
|
|
123
|
+
"""
|
|
124
|
+
Cache layer with graceful fallback for FlashAPI.
|
|
125
|
+
|
|
126
|
+
Usage:
|
|
127
|
+
from flashapi.features.cache import CacheLayer, RedisCache
|
|
128
|
+
|
|
129
|
+
cache = CacheLayer(RedisCache(host='localhost'))
|
|
130
|
+
|
|
131
|
+
# Get (never crashes)
|
|
132
|
+
value = cache.get('users:123') # Returns None if cache down
|
|
133
|
+
|
|
134
|
+
# Set (never crashes)
|
|
135
|
+
cache.set('users:123', {'name': 'John'}, ttl=600)
|
|
136
|
+
|
|
137
|
+
Features:
|
|
138
|
+
- Automatic exception handling (never crashes app)
|
|
139
|
+
- Graceful fallback if cache unavailable
|
|
140
|
+
- Logging for debugging
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(self, backend: CacheBackend | None = None, log_errors: bool = True):
|
|
144
|
+
self.backend = backend or InMemoryCache()
|
|
145
|
+
self.log_errors = log_errors
|
|
146
|
+
|
|
147
|
+
def get(self, key: str) -> Any | None:
|
|
148
|
+
"""Get value from cache. Never crashes."""
|
|
149
|
+
try:
|
|
150
|
+
return self.backend.get(key)
|
|
151
|
+
except Exception as e:
|
|
152
|
+
if self.log_errors:
|
|
153
|
+
print(f"[FlashAPI Cache] GET error: {e}")
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
def set(self, key: str, value: Any, ttl: int = 300) -> bool:
|
|
157
|
+
"""Set value in cache. Never crashes."""
|
|
158
|
+
try:
|
|
159
|
+
return self.backend.set(key, value, ttl)
|
|
160
|
+
except Exception as e:
|
|
161
|
+
if self.log_errors:
|
|
162
|
+
print(f"[FlashAPI Cache] SET error: {e}")
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
def delete(self, key: str) -> bool:
|
|
166
|
+
"""Delete key from cache. Never crashes."""
|
|
167
|
+
try:
|
|
168
|
+
return self.backend.delete(key)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
if self.log_errors:
|
|
171
|
+
print(f"[FlashAPI Cache] DELETE error: {e}")
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
def clear(self) -> bool:
|
|
175
|
+
"""Clear all cache. Never crashes."""
|
|
176
|
+
try:
|
|
177
|
+
return self.backend.clear()
|
|
178
|
+
except Exception as e:
|
|
179
|
+
if self.log_errors:
|
|
180
|
+
print(f"[FlashAPI Cache] CLEAR error: {e}")
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# Global cache instance
|
|
185
|
+
_CACHE: CacheLayer | None = None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def register_cache(cache: CacheLayer) -> None:
|
|
189
|
+
"""Register global cache instance."""
|
|
190
|
+
global _CACHE
|
|
191
|
+
_CACHE = cache
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def get_cache() -> CacheLayer | None:
|
|
195
|
+
"""Get global cache instance."""
|
|
196
|
+
return _CACHE
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# Convenience decorators for route caching
|
|
200
|
+
def cached(ttl: int = 300, key_prefix: str = ""):
|
|
201
|
+
"""
|
|
202
|
+
Decorator to cache route responses.
|
|
203
|
+
|
|
204
|
+
Usage:
|
|
205
|
+
@cached(ttl=600, key_prefix='users')
|
|
206
|
+
def get_user(user_id):
|
|
207
|
+
return {...}
|
|
208
|
+
"""
|
|
209
|
+
def decorator(func):
|
|
210
|
+
def wrapper(*args, **kwargs):
|
|
211
|
+
cache = get_cache()
|
|
212
|
+
if cache is None:
|
|
213
|
+
return func(*args, **kwargs)
|
|
214
|
+
|
|
215
|
+
# Build cache key
|
|
216
|
+
cache_key = f"{key_prefix}:{func.__name__}:{args}:{kwargs}"
|
|
217
|
+
|
|
218
|
+
# Try cache first
|
|
219
|
+
cached_value = cache.get(cache_key)
|
|
220
|
+
if cached_value is not None:
|
|
221
|
+
return cached_value
|
|
222
|
+
|
|
223
|
+
# Cache miss: compute and store
|
|
224
|
+
result = func(*args, **kwargs)
|
|
225
|
+
cache.set(cache_key, result, ttl)
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
return wrapper
|
|
229
|
+
return decorator
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Health check endpoints for production monitoring.
|
|
3
|
+
|
|
4
|
+
Provides /health (liveness) and /ready (readiness) endpoints for Kubernetes,
|
|
5
|
+
load balancers, and monitoring systems.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HealthCheck:
|
|
15
|
+
"""Health check manager with liveness and readiness probes."""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._start_time = time.time()
|
|
19
|
+
self._ready = False
|
|
20
|
+
self._checks: dict[str, callable] = {}
|
|
21
|
+
|
|
22
|
+
def mark_ready(self) -> None:
|
|
23
|
+
"""Mark the application as ready to receive traffic."""
|
|
24
|
+
self._ready = True
|
|
25
|
+
|
|
26
|
+
def register_check(self, name: str, check_fn: callable) -> None:
|
|
27
|
+
"""Register a custom readiness check (e.g., database connection)."""
|
|
28
|
+
self._checks[name] = check_fn
|
|
29
|
+
|
|
30
|
+
def liveness(self) -> dict[str, Any]:
|
|
31
|
+
"""
|
|
32
|
+
Liveness probe — is the application running?
|
|
33
|
+
Returns 200 if alive, should return 5xx if deadlocked/crashed.
|
|
34
|
+
"""
|
|
35
|
+
uptime = int(time.time() - self._start_time)
|
|
36
|
+
return {"status": "ok", "uptime": uptime}
|
|
37
|
+
|
|
38
|
+
def readiness(self) -> tuple[dict[str, Any], int]:
|
|
39
|
+
"""
|
|
40
|
+
Readiness probe — is the application ready to serve traffic?
|
|
41
|
+
Returns 200 if ready, 503 if not yet ready or dependencies failing.
|
|
42
|
+
"""
|
|
43
|
+
if not self._ready:
|
|
44
|
+
return {"status": "not_ready", "reason": "Application still initializing"}, 503
|
|
45
|
+
|
|
46
|
+
failed_checks = []
|
|
47
|
+
for name, check_fn in self._checks.items():
|
|
48
|
+
try:
|
|
49
|
+
if not check_fn():
|
|
50
|
+
failed_checks.append(name)
|
|
51
|
+
except Exception as e:
|
|
52
|
+
failed_checks.append(f"{name}: {e}")
|
|
53
|
+
|
|
54
|
+
if failed_checks:
|
|
55
|
+
return {
|
|
56
|
+
"status": "not_ready",
|
|
57
|
+
"failed_checks": failed_checks,
|
|
58
|
+
}, 503
|
|
59
|
+
|
|
60
|
+
uptime = int(time.time() - self._start_time)
|
|
61
|
+
return {"status": "ready", "uptime": uptime, "checks_passed": len(self._checks)}, 200
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_health = HealthCheck()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_health_check() -> HealthCheck:
|
|
68
|
+
"""Get the global health check instance."""
|
|
69
|
+
return _health
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Idempotency key support for FlashAPI.
|
|
3
|
+
|
|
4
|
+
Prevents duplicate operations (double-click, retry, network replay) by storing
|
|
5
|
+
request/response pairs keyed by a client-provided UUID.
|
|
6
|
+
|
|
7
|
+
HTTP Spec v1 compliant:
|
|
8
|
+
- Header: Idempotency-Key (UUID)
|
|
9
|
+
- Behavior: Same key + same request → replay stored response
|
|
10
|
+
- Conflict: Same key + different request → 422 Unprocessable Entity
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class IdempotencyRecord:
|
|
24
|
+
"""Stored idempotency record."""
|
|
25
|
+
|
|
26
|
+
key: str
|
|
27
|
+
method: str
|
|
28
|
+
path: str
|
|
29
|
+
body_hash: str
|
|
30
|
+
response_status: int
|
|
31
|
+
response_body: str
|
|
32
|
+
response_headers: dict[str, str]
|
|
33
|
+
created_at: float
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class IdempotencyStore:
|
|
37
|
+
"""Abstract storage interface for idempotency records."""
|
|
38
|
+
|
|
39
|
+
def get(self, key: str) -> IdempotencyRecord | None:
|
|
40
|
+
"""Retrieve stored record by key."""
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
|
|
43
|
+
def store(
|
|
44
|
+
self,
|
|
45
|
+
key: str,
|
|
46
|
+
method: str,
|
|
47
|
+
path: str,
|
|
48
|
+
body_hash: str,
|
|
49
|
+
response_status: int,
|
|
50
|
+
response_body: str,
|
|
51
|
+
response_headers: dict[str, str],
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Store a new idempotency record."""
|
|
54
|
+
raise NotImplementedError
|
|
55
|
+
|
|
56
|
+
def cleanup_expired(self, max_age_seconds: int = 86400) -> int:
|
|
57
|
+
"""Remove records older than max_age_seconds. Returns count deleted."""
|
|
58
|
+
raise NotImplementedError
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class InMemoryIdempotencyStore(IdempotencyStore):
|
|
62
|
+
"""In-memory idempotency store (for development/testing)."""
|
|
63
|
+
|
|
64
|
+
def __init__(self):
|
|
65
|
+
self._store: dict[str, IdempotencyRecord] = {}
|
|
66
|
+
|
|
67
|
+
def get(self, key: str) -> IdempotencyRecord | None:
|
|
68
|
+
return self._store.get(key)
|
|
69
|
+
|
|
70
|
+
def store(
|
|
71
|
+
self,
|
|
72
|
+
key: str,
|
|
73
|
+
method: str,
|
|
74
|
+
path: str,
|
|
75
|
+
body_hash: str,
|
|
76
|
+
response_status: int,
|
|
77
|
+
response_body: str,
|
|
78
|
+
response_headers: dict[str, str],
|
|
79
|
+
) -> None:
|
|
80
|
+
self._store[key] = IdempotencyRecord(
|
|
81
|
+
key=key,
|
|
82
|
+
method=method,
|
|
83
|
+
path=path,
|
|
84
|
+
body_hash=body_hash,
|
|
85
|
+
response_status=response_status,
|
|
86
|
+
response_body=response_body,
|
|
87
|
+
response_headers=response_headers,
|
|
88
|
+
created_at=time.time(),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def cleanup_expired(self, max_age_seconds: int = 86400) -> int:
|
|
92
|
+
now = time.time()
|
|
93
|
+
expired_keys = [
|
|
94
|
+
k for k, v in self._store.items() if now - v.created_at > max_age_seconds
|
|
95
|
+
]
|
|
96
|
+
for key in expired_keys:
|
|
97
|
+
del self._store[key]
|
|
98
|
+
return len(expired_keys)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class SQLiteIdempotencyStore(IdempotencyStore):
|
|
102
|
+
"""SQLite-based idempotency store (production-ready for single-instance)."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, db_path: str = ":memory:"):
|
|
105
|
+
import sqlite3
|
|
106
|
+
|
|
107
|
+
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
|
108
|
+
self._conn.row_factory = sqlite3.Row
|
|
109
|
+
self._init_table()
|
|
110
|
+
|
|
111
|
+
def _init_table(self):
|
|
112
|
+
self._conn.execute("""
|
|
113
|
+
CREATE TABLE IF NOT EXISTS idempotency_keys (
|
|
114
|
+
key TEXT PRIMARY KEY,
|
|
115
|
+
method TEXT NOT NULL,
|
|
116
|
+
path TEXT NOT NULL,
|
|
117
|
+
body_hash TEXT NOT NULL,
|
|
118
|
+
response_status INTEGER NOT NULL,
|
|
119
|
+
response_body TEXT NOT NULL,
|
|
120
|
+
response_headers TEXT NOT NULL,
|
|
121
|
+
created_at REAL NOT NULL
|
|
122
|
+
)
|
|
123
|
+
""")
|
|
124
|
+
self._conn.execute(
|
|
125
|
+
"CREATE INDEX IF NOT EXISTS idx_created_at ON idempotency_keys(created_at)"
|
|
126
|
+
)
|
|
127
|
+
self._conn.commit()
|
|
128
|
+
|
|
129
|
+
def get(self, key: str) -> IdempotencyRecord | None:
|
|
130
|
+
row = self._conn.execute(
|
|
131
|
+
"SELECT * FROM idempotency_keys WHERE key = ?", (key,)
|
|
132
|
+
).fetchone()
|
|
133
|
+
if row is None:
|
|
134
|
+
return None
|
|
135
|
+
return IdempotencyRecord(
|
|
136
|
+
key=row["key"],
|
|
137
|
+
method=row["method"],
|
|
138
|
+
path=row["path"],
|
|
139
|
+
body_hash=row["body_hash"],
|
|
140
|
+
response_status=row["response_status"],
|
|
141
|
+
response_body=row["response_body"],
|
|
142
|
+
response_headers=json.loads(row["response_headers"]),
|
|
143
|
+
created_at=row["created_at"],
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def store(
|
|
147
|
+
self,
|
|
148
|
+
key: str,
|
|
149
|
+
method: str,
|
|
150
|
+
path: str,
|
|
151
|
+
body_hash: str,
|
|
152
|
+
response_status: int,
|
|
153
|
+
response_body: str,
|
|
154
|
+
response_headers: dict[str, str],
|
|
155
|
+
) -> None:
|
|
156
|
+
self._conn.execute(
|
|
157
|
+
"""
|
|
158
|
+
INSERT OR REPLACE INTO idempotency_keys
|
|
159
|
+
(key, method, path, body_hash, response_status, response_body, response_headers, created_at)
|
|
160
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
161
|
+
""",
|
|
162
|
+
(
|
|
163
|
+
key,
|
|
164
|
+
method,
|
|
165
|
+
path,
|
|
166
|
+
body_hash,
|
|
167
|
+
response_status,
|
|
168
|
+
response_body,
|
|
169
|
+
json.dumps(response_headers),
|
|
170
|
+
time.time(),
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
self._conn.commit()
|
|
174
|
+
|
|
175
|
+
def cleanup_expired(self, max_age_seconds: int = 86400) -> int:
|
|
176
|
+
cutoff = time.time() - max_age_seconds
|
|
177
|
+
cursor = self._conn.execute(
|
|
178
|
+
"DELETE FROM idempotency_keys WHERE created_at < ?", (cutoff,)
|
|
179
|
+
)
|
|
180
|
+
self._conn.commit()
|
|
181
|
+
return cursor.rowcount
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def compute_body_hash(body: bytes | str) -> str:
|
|
185
|
+
"""Compute SHA-256 hash of request body for comparison."""
|
|
186
|
+
if isinstance(body, str):
|
|
187
|
+
body = body.encode("utf-8")
|
|
188
|
+
return hashlib.sha256(body).hexdigest()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def check_idempotency_conflict(
|
|
192
|
+
stored: IdempotencyRecord, method: str, path: str, body_hash: str
|
|
193
|
+
) -> bool:
|
|
194
|
+
"""Check if request conflicts with stored record (same key, different request)."""
|
|
195
|
+
return (
|
|
196
|
+
stored.method != method or stored.path != path or stored.body_hash != body_hash
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class IdempotencyMiddleware:
|
|
201
|
+
"""
|
|
202
|
+
Idempotency middleware for FlashAPI.
|
|
203
|
+
|
|
204
|
+
Usage (Django):
|
|
205
|
+
from flashapi.features.idempotency import IdempotencyMiddleware, SQLiteIdempotencyStore
|
|
206
|
+
|
|
207
|
+
# In settings.py MIDDLEWARE
|
|
208
|
+
MIDDLEWARE = [
|
|
209
|
+
'flashapi.features.idempotency.DjangoIdempotencyMiddleware',
|
|
210
|
+
...
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
# Register store globally
|
|
214
|
+
from flashapi.features.idempotency import register_idempotency_store
|
|
215
|
+
register_idempotency_store(SQLiteIdempotencyStore('idempotency.db'))
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
def __init__(self, store: IdempotencyStore | None = None):
|
|
219
|
+
self.store = store or InMemoryIdempotencyStore()
|
|
220
|
+
|
|
221
|
+
def process_request(
|
|
222
|
+
self, method: str, path: str, headers: dict[str, str], body: bytes | str
|
|
223
|
+
) -> tuple[bool, Any]:
|
|
224
|
+
"""
|
|
225
|
+
Process request for idempotency.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
(is_replay, response_or_none):
|
|
229
|
+
- If is_replay=True, response_or_none contains the stored response to replay
|
|
230
|
+
- If is_replay=False, response_or_none is None (continue normal processing)
|
|
231
|
+
- If is_replay=False but response_or_none is dict, it's a 422 conflict error
|
|
232
|
+
"""
|
|
233
|
+
# Only apply to POST/PUT/DELETE
|
|
234
|
+
if method not in ("POST", "PUT", "DELETE"):
|
|
235
|
+
return False, None
|
|
236
|
+
|
|
237
|
+
idempotency_key = headers.get("idempotency-key") or headers.get(
|
|
238
|
+
"Idempotency-Key"
|
|
239
|
+
)
|
|
240
|
+
if not idempotency_key:
|
|
241
|
+
return False, None
|
|
242
|
+
|
|
243
|
+
# Validate UUID format (basic check)
|
|
244
|
+
if len(idempotency_key) < 32:
|
|
245
|
+
return False, {
|
|
246
|
+
"error": "Invalid Idempotency-Key format (must be UUID)",
|
|
247
|
+
"status": 400,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
# Check if key exists
|
|
251
|
+
stored = self.store.get(idempotency_key)
|
|
252
|
+
if stored is None:
|
|
253
|
+
return False, None
|
|
254
|
+
|
|
255
|
+
# Check for conflict (same key, different request)
|
|
256
|
+
body_hash = compute_body_hash(body)
|
|
257
|
+
if check_idempotency_conflict(stored, method, path, body_hash):
|
|
258
|
+
return False, {
|
|
259
|
+
"error": {
|
|
260
|
+
"code": "IDEMPOTENCY_CONFLICT",
|
|
261
|
+
"message": "Idempotency key reused with different request",
|
|
262
|
+
"status": 422,
|
|
263
|
+
},
|
|
264
|
+
"status": 422,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
# Replay stored response
|
|
268
|
+
return True, {
|
|
269
|
+
"status": stored.response_status,
|
|
270
|
+
"body": stored.response_body,
|
|
271
|
+
"headers": stored.response_headers,
|
|
272
|
+
"replay": True,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
def store_response(
|
|
276
|
+
self,
|
|
277
|
+
idempotency_key: str,
|
|
278
|
+
method: str,
|
|
279
|
+
path: str,
|
|
280
|
+
body: bytes | str,
|
|
281
|
+
response_status: int,
|
|
282
|
+
response_body: str,
|
|
283
|
+
response_headers: dict[str, str] | None = None,
|
|
284
|
+
) -> None:
|
|
285
|
+
"""Store response for future replay."""
|
|
286
|
+
if method not in ("POST", "PUT", "DELETE"):
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
body_hash = compute_body_hash(body)
|
|
290
|
+
self.store.store(
|
|
291
|
+
key=idempotency_key,
|
|
292
|
+
method=method,
|
|
293
|
+
path=path,
|
|
294
|
+
body_hash=body_hash,
|
|
295
|
+
response_status=response_status,
|
|
296
|
+
response_body=response_body,
|
|
297
|
+
response_headers=response_headers or {},
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# Global store registry (for Django middleware)
|
|
302
|
+
_IDEMPOTENCY_STORE: IdempotencyStore | None = None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def register_idempotency_store(store: IdempotencyStore) -> None:
|
|
306
|
+
"""Register global idempotency store."""
|
|
307
|
+
global _IDEMPOTENCY_STORE
|
|
308
|
+
_IDEMPOTENCY_STORE = store
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def get_idempotency_store() -> IdempotencyStore | None:
|
|
312
|
+
"""Get global idempotency store."""
|
|
313
|
+
return _IDEMPOTENCY_STORE
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# Django middleware class
|
|
317
|
+
class DjangoIdempotencyMiddleware:
|
|
318
|
+
"""Django middleware for idempotency support."""
|
|
319
|
+
|
|
320
|
+
def __init__(self, get_response):
|
|
321
|
+
self.get_response = get_response
|
|
322
|
+
self.middleware = IdempotencyMiddleware(get_idempotency_store())
|
|
323
|
+
|
|
324
|
+
def __call__(self, request):
|
|
325
|
+
from django.http import JsonResponse
|
|
326
|
+
|
|
327
|
+
# Extract request data
|
|
328
|
+
method = request.method
|
|
329
|
+
path = request.path
|
|
330
|
+
headers = {k.lower(): v for k, v in request.META.items() if k.startswith("HTTP_")}
|
|
331
|
+
headers = {k.replace("http_", ""): v for k, v in headers.items()}
|
|
332
|
+
body = request.body
|
|
333
|
+
|
|
334
|
+
# Check idempotency
|
|
335
|
+
is_replay, result = self.middleware.process_request(method, path, headers, body)
|
|
336
|
+
|
|
337
|
+
if result and "status" in result and result["status"] in (400, 422):
|
|
338
|
+
# Error response (invalid key or conflict)
|
|
339
|
+
return JsonResponse(result.get("error", result), status=result["status"])
|
|
340
|
+
|
|
341
|
+
if is_replay and result:
|
|
342
|
+
# Replay stored response
|
|
343
|
+
response = JsonResponse(
|
|
344
|
+
json.loads(result["body"]), status=result["status"], safe=False
|
|
345
|
+
)
|
|
346
|
+
for key, value in result.get("headers", {}).items():
|
|
347
|
+
response[key] = value
|
|
348
|
+
response["Idempotency-Replay"] = "true"
|
|
349
|
+
return response
|
|
350
|
+
|
|
351
|
+
# Process request normally
|
|
352
|
+
response = self.get_response(request)
|
|
353
|
+
|
|
354
|
+
# Store response if idempotency key present and success
|
|
355
|
+
idempotency_key = headers.get("idempotency-key")
|
|
356
|
+
if (
|
|
357
|
+
idempotency_key
|
|
358
|
+
and method in ("POST", "PUT", "DELETE")
|
|
359
|
+
and 200 <= response.status_code < 300
|
|
360
|
+
):
|
|
361
|
+
response_body = response.content.decode("utf-8")
|
|
362
|
+
response_headers = {
|
|
363
|
+
k: v for k, v in response.items() if k.lower() in ("content-type",)
|
|
364
|
+
}
|
|
365
|
+
self.middleware.store_response(
|
|
366
|
+
idempotency_key=idempotency_key,
|
|
367
|
+
method=method,
|
|
368
|
+
path=path,
|
|
369
|
+
body=body,
|
|
370
|
+
response_status=response.status_code,
|
|
371
|
+
response_body=response_body,
|
|
372
|
+
response_headers=response_headers,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
return response
|
flashapi/storage/orm.py
CHANGED
|
@@ -38,6 +38,9 @@ class DjangoORMStorage(Storage):
|
|
|
38
38
|
instance = self._get_instance(item_id, lookup_field)
|
|
39
39
|
if instance is None:
|
|
40
40
|
return None
|
|
41
|
+
# Fix Bug #3: Respect soft delete in GET /{id}
|
|
42
|
+
if self._has_deleted_at and getattr(instance, SOFT_DELETE_FIELD, None) is not None:
|
|
43
|
+
return None
|
|
41
44
|
return self._to_dict(instance)
|
|
42
45
|
|
|
43
46
|
def list_all(self, table: str, *, include_deleted: bool = False, only_deleted: bool = False) -> list[dict[str, Any]]:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: python-flashapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Define your models. FlashAPI does the rest.
|
|
5
5
|
Project-URL: Homepage, https://github.com/HackermanMe/flashapi
|
|
6
6
|
Project-URL: Documentation, https://github.com/HackermanMe/flashapi#readme
|
|
@@ -64,10 +64,12 @@ Description-Content-Type: text/markdown
|
|
|
64
64
|
|
|
65
65
|
---
|
|
66
66
|
|
|
67
|
-
FlashAPI generates a full REST API with CRUD, pagination, filtering, sorting, full-text search, relations, soft delete, bulk operations, export, audit trail, webhooks, rate limiting, and a live dashboard — from your existing models, in one line.
|
|
67
|
+
FlashAPI generates a full REST API with CRUD, pagination, filtering, sorting, full-text search, relations, soft delete, bulk operations, export, audit trail, webhooks, rate limiting, **idempotency keys**, **cache layer**, **currentUser auto-injection**, and a live dashboard — from your existing models, in one line.
|
|
68
68
|
|
|
69
69
|
Part of the **FlashAPI Ecosystem** — ensuring SDK client compatibility across all backends (Python, Java Spring, Node.js).
|
|
70
70
|
|
|
71
|
+
**New in 0.4.0:** Idempotency keys (prevent double-click/retry duplicates), cache layer with graceful fallback, currentUserField auto-injection, and audit enabled by default.
|
|
72
|
+
|
|
71
73
|
---
|
|
72
74
|
|
|
73
75
|
## Documentation
|
|
@@ -88,7 +90,12 @@ Part of the **FlashAPI Ecosystem** — ensuring SDK client compatibility across
|
|
|
88
90
|
## Installation
|
|
89
91
|
|
|
90
92
|
```bash
|
|
91
|
-
|
|
93
|
+
# x-release-please-start-version
|
|
94
|
+
pip install python-flashapi==0.3.0
|
|
95
|
+
# x-release-please-end
|
|
96
|
+
|
|
97
|
+
# With framework extras:
|
|
98
|
+
pip install python-flashapi[fastapi] # or [flask] or [django] or [all]
|
|
92
99
|
```
|
|
93
100
|
|
|
94
101
|
---
|
|
@@ -226,8 +233,9 @@ FlashAPI(
|
|
|
226
233
|
Model(Config, readonly=True, access="admin"), # GET only, admin
|
|
227
234
|
Model(Log, only=["list"]), # List only
|
|
228
235
|
Model(Animal, plural="animaux"), # Custom plural
|
|
229
|
-
Model(Invoice, soft_delete=True, audit=True), # Opt-in features
|
|
236
|
+
Model(Invoice, soft_delete=True, audit=True), # Opt-in features (audit=True by default)
|
|
230
237
|
Model(Eleve, access="staff", scope="tenant", tenant_field="ecole_id"),
|
|
238
|
+
Model(Post, current_user_field="author"), # Auto-inject authenticated user on create
|
|
231
239
|
],
|
|
232
240
|
base_path="/api", # Configurable prefix (default: /api)
|
|
233
241
|
auth_backend=MyAuth(), # Your AuthBackend implementation
|
|
@@ -235,6 +243,14 @@ FlashAPI(
|
|
|
235
243
|
rate_limit=100, # 100 requests per window
|
|
236
244
|
rate_window=60, # 60 seconds window
|
|
237
245
|
)
|
|
246
|
+
|
|
247
|
+
# Enable idempotency (prevents duplicate operations from double-click/retry)
|
|
248
|
+
# Client sends: Idempotency-Key: <uuid> header
|
|
249
|
+
# Same key + same request → returns stored response (no duplicate created)
|
|
250
|
+
|
|
251
|
+
# Enable cache layer (graceful fallback if Redis down)
|
|
252
|
+
from flashapi.features.cache import CacheLayer, RedisCache, register_cache
|
|
253
|
+
register_cache(CacheLayer(RedisCache(host='localhost')))
|
|
238
254
|
```
|
|
239
255
|
|
|
240
256
|
See [Customization docs](docs/customization.md) for all options.
|
|
@@ -5,24 +5,27 @@ flashapi/flask.py,sha256=v-uSb9QSxj20w2iyDgaM4uED82XHLWEO9CRxGT8KCVc,137
|
|
|
5
5
|
flashapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
6
|
flashapi/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
7
|
flashapi/adapters/base.py,sha256=DbXmwMZzZYums4YaJ-OlY8bJxTbGJTxApSfmf5NO-0w,329
|
|
8
|
-
flashapi/adapters/django.py,sha256=
|
|
9
|
-
flashapi/adapters/fastapi.py,sha256=
|
|
10
|
-
flashapi/adapters/flask.py,sha256=
|
|
8
|
+
flashapi/adapters/django.py,sha256=KVkPjL8kPHfeXnJb67h0mf6K0_SjRSpPo1B5zfpexHY,35743
|
|
9
|
+
flashapi/adapters/fastapi.py,sha256=OvdcBVT9j17ldLymzs4Hm3r-zzotDiHdht6cA5sZPYE,38779
|
|
10
|
+
flashapi/adapters/flask.py,sha256=pwYqy-X2ABmbCeWz5NMOXWKYvp2ZHlE_digouKvY-xs,30880
|
|
11
11
|
flashapi/core/__init__.py,sha256=8Ex4oCFI34F4xkA6Q6zSSQ-jE2uw1w-C20yl-Yg3f3c,297
|
|
12
|
-
flashapi/core/custom_routes.py,sha256
|
|
12
|
+
flashapi/core/custom_routes.py,sha256=iPaupBqc_j0yaCsxZeoGS6iEqlSD4s26EpM27XoIAwg,9456
|
|
13
13
|
flashapi/core/pluralize.py,sha256=nL_JwNHwEbz6n3HXdfA1We9_MRNR-cLn4VT-Hz1RW1g,2406
|
|
14
14
|
flashapi/core/relations.py,sha256=vhy3qJeL_cwJFNq1h-3OtE9hXr6MJ9P23hSBtmX-7Zg,2236
|
|
15
15
|
flashapi/core/response.py,sha256=6jJ2xIiSn48WKWOBHjM_LnnpScVjcJjA4idCKOaWfqc,847
|
|
16
|
-
flashapi/core/schema.py,sha256=
|
|
17
|
-
flashapi/core/visibility.py,sha256=
|
|
16
|
+
flashapi/core/schema.py,sha256=Ers-rmUoYS4-cLbgoWj1_87hNBqDPic6J1H13SG5AYY,4405
|
|
17
|
+
flashapi/core/visibility.py,sha256=HJsySZ_PuRA0dTO5YPmd2pBv3opCRW0nty5ZKzUKI4A,2526
|
|
18
18
|
flashapi/docs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
19
|
flashapi/docs/openapi.py,sha256=jfrQMC3wPAv3AcLRBXDmaB45D-LctCWbBFLiq5lsUuI,10931
|
|
20
20
|
flashapi/features/__init__.py,sha256=EHYe6pSe74LegaSRdX09X--a0M022fkRZFomWn84kFc,280
|
|
21
21
|
flashapi/features/audit.py,sha256=kJ9U1FbA6HQI-bjUjcOiwsY3J300O2aoF9fvrLFI4k8,2870
|
|
22
22
|
flashapi/features/auth.py,sha256=FVYb6x6VAIEvAp2xY8LL5I2E2PFdPe5v8dDGmWQfWLc,4585
|
|
23
|
+
flashapi/features/cache.py,sha256=gBoUiU0oQNAbVyBhVTLnlRDNrWo5iOm-VqWvdyd3vsc,6350
|
|
23
24
|
flashapi/features/dashboard.py,sha256=eaOJmrJ5lRxDDyo4BhCp5qaNHDn0AUW4IlqhAIL38as,18239
|
|
24
25
|
flashapi/features/export.py,sha256=M7RryPDgDZn1VlouZNzhvhrZ00k1e0HyyZlAfjYMSiE,4912
|
|
25
26
|
flashapi/features/filtering.py,sha256=ONPWbVVTHlTRlxUoOcRNkL_a67osr12CJHKKBXHwDPg,3826
|
|
27
|
+
flashapi/features/health.py,sha256=g-vLoIAmS2L8pq7cvG9TX7h60rPEAa6YMlog71AW-u4,2140
|
|
28
|
+
flashapi/features/idempotency.py,sha256=cdrIJeTLGZHRoqn_zAu-UPOU2_kCgL9E7qGOEjsmoOA,11812
|
|
26
29
|
flashapi/features/pagination.py,sha256=396lvL_c6j3tNNodpPHkYDRkxkDmex2q9HmrUeOpLRA,464
|
|
27
30
|
flashapi/features/rate_limit.py,sha256=LEI8UqWHEH7TREqaD0lDhhR5jZz8VF5qdu0gBxOVBSc,1313
|
|
28
31
|
flashapi/features/search.py,sha256=0V0S8Mo2zb_BEeGbNYg8Y4ZF9njLNhwZAkrVrODW3Dc,611
|
|
@@ -39,10 +42,10 @@ flashapi/inspectors/sqlalchemy.py,sha256=mVCnBCA7MhGqGsrhtZJA2LS5luZiU8wcXHpvv4U
|
|
|
39
42
|
flashapi/storage/__init__.py,sha256=h8bXTCL-y6P_waQ3L2JNEh8lVaSJ_WiEVkaolUsOhUs,126
|
|
40
43
|
flashapi/storage/auto.py,sha256=6K6_TSFU2NJSIaFqE3qbGFIY1H8uaL0iLGBEVvUpifs,7611
|
|
41
44
|
flashapi/storage/base.py,sha256=8C6DL9LAWQ3lIDFKTKD3chXuDNr2LTc5yWoZYQPpKq4,1089
|
|
42
|
-
flashapi/storage/orm.py,sha256=
|
|
45
|
+
flashapi/storage/orm.py,sha256=q_PeFaoS6UrJnxsaSwXx2H1bzv2vUK2G8KbmH-msRoY,4727
|
|
43
46
|
flashapi/storage/sqlalchemy.py,sha256=FoSHWDydicRvns8ufO1vnb2o0dGwvASd88sQ3wh1SAI,6651
|
|
44
|
-
python_flashapi-0.
|
|
45
|
-
python_flashapi-0.
|
|
46
|
-
python_flashapi-0.
|
|
47
|
-
python_flashapi-0.
|
|
48
|
-
python_flashapi-0.
|
|
47
|
+
python_flashapi-0.4.0.dist-info/METADATA,sha256=AYSQHfaX8gI_dpw0TQ1UzKj0_ZITpnB8s-8BqL6p90Q,11655
|
|
48
|
+
python_flashapi-0.4.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
|
|
49
|
+
python_flashapi-0.4.0.dist-info/licenses/LICENSE,sha256=thh4-7oEXj4Tz_CHQciJHSnRqxCNRRuse5ac-BwBU5c,10772
|
|
50
|
+
python_flashapi-0.4.0.dist-info/licenses/NOTICE,sha256=5-7uAManZL-6L6TGoHqSVBioA5MZ_UAzCPi97Ahtvw8,164
|
|
51
|
+
python_flashapi-0.4.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|