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.
@@ -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
+ ]
@@ -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:
@@ -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()
@@ -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
- paths[path_str][method] = _build_openapi_operation(doc, method)
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 = False,
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(
@@ -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.4
1
+ Metadata-Version: 2.5
2
2
  Name: python-flashapi
3
- Version: 0.2.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
- pip install python-flashapi[fastapi] # or python-flashapi[flask] or python-flashapi[all]
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=6rs_mFkkF4qmnJ8DB69nV8mbYuMde1bHCzLBrN5MypI,32633
9
- flashapi/adapters/fastapi.py,sha256=63lcUoK2a6k1RdwRb1kIH2zz-D0tqouJ3MUALUMJ0WI,36820
10
- flashapi/adapters/flask.py,sha256=peVQQ8NTGDLpuplAvUbYTuXGF59eeBbFb_9HTEcxvfE,29121
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=-K0xDx-bW1yJtcJxh5ZdFUI0MzwIKoNKMJCuocdZuWI,8432
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=bxuQv2knRj_SqZQa3fHhS_aO72TKwpXYNFu0xti5Cms,4176
17
- flashapi/core/visibility.py,sha256=ZPa7zK3-Dp0AoaFH7VA04eWE4vw7oia5aH8wCz0nHxw,1398
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=F_hOxspYkKzH1l_m48jRNdv7TGvp4OM6EwpMixfeP9E,4556
45
+ flashapi/storage/orm.py,sha256=q_PeFaoS6UrJnxsaSwXx2H1bzv2vUK2G8KbmH-msRoY,4727
43
46
  flashapi/storage/sqlalchemy.py,sha256=FoSHWDydicRvns8ufO1vnb2o0dGwvASd88sQ3wh1SAI,6651
44
- python_flashapi-0.2.0.dist-info/METADATA,sha256=nRRFMQ3s7x8n0HomOcd-B1bh1pGITxazny9vczB-1tU,10793
45
- python_flashapi-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
46
- python_flashapi-0.2.0.dist-info/licenses/LICENSE,sha256=thh4-7oEXj4Tz_CHQciJHSnRqxCNRRuse5ac-BwBU5c,10772
47
- python_flashapi-0.2.0.dist-info/licenses/NOTICE,sha256=5-7uAManZL-6L6TGoHqSVBioA5MZ_UAzCPi97Ahtvw8,164
48
- python_flashapi-0.2.0.dist-info/RECORD,,
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.31.0
2
+ Generator: hatchling 1.32.3
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any