python-flashapi 0.1.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/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """FlashAPI — Define your models. FlashAPI does the rest."""
2
+
3
+ from flashapi.core.schema import Model
4
+ from flashapi.core.custom_routes import CustomRoute, RouteParam, RouteBody, api_doc
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = ["Model", "CustomRoute", "RouteParam", "RouteBody", "api_doc"]
File without changes
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+ from flashapi.core.schema import ModelSchema
7
+
8
+
9
+ class Adapter(ABC):
10
+ @abstractmethod
11
+ def register_model(self, schema: ModelSchema, permissions: list[str], storage: Any) -> None:
12
+ ...
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Callable
4
+
5
+ from flashapi.core.schema import Model, ModelSchema
6
+ from flashapi.core.response import create_list_response, create_item_response
7
+ from flashapi.core.custom_routes import (
8
+ CustomRoute, custom_routes_to_openapi_paths, discover_django_views,
9
+ )
10
+ from flashapi.features import paginate, apply_filters, apply_sorting, apply_search
11
+ from flashapi.inspectors import inspect_model
12
+ from flashapi.storage.orm import DjangoORMStorage
13
+ from flashapi.docs.openapi import generate_openapi_schema, get_swagger_html
14
+
15
+
16
+ def generate_urls(
17
+ models: list[type | Model],
18
+ *,
19
+ custom_routes: list[CustomRoute] | None = None,
20
+ extra_views: list | None = None,
21
+ docs: bool = True,
22
+ formatter: Callable | None = None,
23
+ ):
24
+ """Generate Django URL patterns for the given models.
25
+
26
+ Custom views decorated with @api_doc are auto-discovered from extra_views
27
+ or from all urlpatterns in the same file.
28
+ """
29
+
30
+ urlpatterns = []
31
+ all_schemas: list[ModelSchema] = []
32
+
33
+ for model_entry in models:
34
+ if isinstance(model_entry, Model):
35
+ wrapper = model_entry
36
+ else:
37
+ wrapper = Model(model_entry)
38
+
39
+ schema = inspect_model(wrapper.model_class, plural=wrapper.plural)
40
+ schema.permissions = wrapper.permissions
41
+ storage = DjangoORMStorage(wrapper.model_class)
42
+ all_schemas.append(schema)
43
+ patterns = _create_django_views(schema, storage, formatter)
44
+ urlpatterns.extend(patterns)
45
+
46
+ if docs:
47
+ urlpatterns.extend(_create_docs_views(
48
+ all_schemas, custom_routes or [], extra_views or [],
49
+ ))
50
+
51
+ return urlpatterns
52
+
53
+
54
+ def _create_docs_views(schemas: list[ModelSchema], custom_routes: list[CustomRoute], extra_views: list):
55
+ from django.urls import path
56
+ from django.http import JsonResponse, HttpResponse
57
+
58
+ openapi_spec = generate_openapi_schema(schemas, trailing_slash=True)
59
+
60
+ # Method 1: explicit CustomRoute objects
61
+ if custom_routes:
62
+ custom_paths = custom_routes_to_openapi_paths(custom_routes, trailing_slash=True)
63
+ openapi_spec["paths"].update(custom_paths)
64
+
65
+ # Method 2: auto-discover @api_doc decorated views from extra_views (URL patterns)
66
+ if extra_views:
67
+ discovered = discover_django_views(extra_views, trailing_slash=True)
68
+ openapi_spec["paths"].update(discovered)
69
+
70
+ def openapi_json(request):
71
+ spec = dict(openapi_spec)
72
+ base_path = request.path.rsplit("openapi.json", 1)[0]
73
+ spec["servers"] = [{"url": base_path}]
74
+ return JsonResponse(spec, safe=False)
75
+
76
+ def docs_ui(request):
77
+ base_path = request.path.rsplit("docs/", 1)[0]
78
+ openapi_url = f"{base_path}openapi.json"
79
+ html = get_swagger_html(title="FlashAPI", openapi_url=openapi_url)
80
+ return HttpResponse(html, content_type="text/html")
81
+
82
+ return [
83
+ path("openapi.json", openapi_json, name="flashapi_openapi"),
84
+ path("docs/", docs_ui, name="flashapi_docs"),
85
+ ]
86
+
87
+
88
+ def _create_django_views(
89
+ schema: ModelSchema,
90
+ storage: DjangoORMStorage,
91
+ formatter: Callable | None,
92
+ ):
93
+ from django.urls import path
94
+ from django.http import JsonResponse
95
+ from django.views.decorators.csrf import csrf_exempt
96
+ import json
97
+
98
+ table = schema.plural
99
+ field_names = {f.name for f in schema.fields if not f.primary_key}
100
+ patterns = []
101
+
102
+ if "list" in schema.permissions or "create" in schema.permissions:
103
+
104
+ def collection_view(request, _table=table, _fields=field_names, _schema=schema):
105
+ if request.method == "GET" and "list" in _schema.permissions:
106
+ items = storage.list_all(_table)
107
+ params = dict(request.GET)
108
+ params = {k: v[0] if isinstance(v, list) else v for k, v in params.items()}
109
+ try:
110
+ page = max(1, int(params.get("page", 1)))
111
+ page_size = max(1, min(100, int(params.get("page_size", 20))))
112
+ except (ValueError, TypeError):
113
+ return JsonResponse({"error": "Invalid page or page_size parameter"}, status=400)
114
+ sort = params.get("sort")
115
+ search = params.get("search")
116
+
117
+ items = apply_filters(items, params, _fields)
118
+ items = apply_search(items, search, _fields)
119
+ items = apply_sorting(items, sort, _fields)
120
+ page_items, total = paginate(items, page, page_size)
121
+ return JsonResponse(
122
+ create_list_response(page_items, total, page, page_size, formatter)
123
+ )
124
+
125
+ elif request.method == "POST" and "create" in _schema.permissions:
126
+ try:
127
+ body = json.loads(request.body)
128
+ except (json.JSONDecodeError, ValueError):
129
+ return JsonResponse({"error": "Invalid JSON body"}, status=400)
130
+ data = {k: v for k, v in body.items() if k in _fields}
131
+ item = storage.create(_table, data)
132
+ return JsonResponse(create_item_response(item, formatter), status=201)
133
+
134
+ return JsonResponse({"error": "Method not allowed"}, status=405)
135
+
136
+ patterns.append(path(f"{table}/", csrf_exempt(collection_view), name=f"{table}_collection"))
137
+
138
+ if any(op in schema.permissions for op in ["read", "update", "delete"]):
139
+
140
+ def detail_view(request, item_id, _table=table, _fields=field_names, _schema=schema):
141
+ if request.method == "GET" and "read" in _schema.permissions:
142
+ item = storage.get(_table, item_id)
143
+ if item is None:
144
+ return JsonResponse({"error": "Not found"}, status=404)
145
+ return JsonResponse(create_item_response(item, formatter))
146
+
147
+ elif request.method == "PUT" and "update" in _schema.permissions:
148
+ body = json.loads(request.body)
149
+ data = {k: v for k, v in body.items() if k in _fields}
150
+ item = storage.update(_table, item_id, data)
151
+ if item is None:
152
+ return JsonResponse({"error": "Not found"}, status=404)
153
+ return JsonResponse(create_item_response(item, formatter))
154
+
155
+ elif request.method == "DELETE" and "delete" in _schema.permissions:
156
+ deleted = storage.delete(_table, item_id)
157
+ if not deleted:
158
+ return JsonResponse({"error": "Not found"}, status=404)
159
+ return JsonResponse({}, status=204)
160
+
161
+ return JsonResponse({"error": "Method not allowed"}, status=405)
162
+
163
+ patterns.append(path(f"{table}/<int:item_id>/", csrf_exempt(detail_view), name=f"{table}_detail"))
164
+
165
+ return patterns
@@ -0,0 +1,293 @@
1
+ from typing import Any, Callable, Optional
2
+ from datetime import date, datetime, time
3
+ import uuid
4
+
5
+ from fastapi import FastAPI, HTTPException, Query, Request
6
+ from pydantic import BaseModel, create_model
7
+
8
+ from flashapi.core.schema import Model, ModelSchema, FieldType
9
+ from flashapi.core.response import create_list_response, create_item_response
10
+ from flashapi.core.relations import resolve_relations, find_expandable_fields
11
+ from flashapi.features import paginate, apply_filters, apply_sorting, apply_search
12
+ from flashapi.inspectors import inspect_model
13
+ from flashapi.storage.auto import AutoStorage
14
+ from flashapi.storage.sqlalchemy import SQLAlchemyStorage
15
+
16
+
17
+ FIELD_TYPE_TO_PYTHON = {
18
+ FieldType.STRING: str,
19
+ FieldType.TEXT: str,
20
+ FieldType.INTEGER: int,
21
+ FieldType.FLOAT: float,
22
+ FieldType.BOOLEAN: bool,
23
+ FieldType.DATE: date,
24
+ FieldType.DATETIME: datetime,
25
+ FieldType.TIME: time,
26
+ FieldType.UUID: uuid.UUID,
27
+ FieldType.JSON: dict,
28
+ FieldType.BINARY: bytes,
29
+ }
30
+
31
+
32
+ def _build_pydantic_model(schema: ModelSchema, *, all_optional: bool = False) -> type[BaseModel]:
33
+ """Create a Pydantic model from a ModelSchema for request body validation."""
34
+ fields = {}
35
+ for f in schema.fields:
36
+ if f.primary_key and f.auto_generated:
37
+ continue
38
+ python_type = FIELD_TYPE_TO_PYTHON.get(f.type, str)
39
+ if f.required and not all_optional:
40
+ fields[f.name] = (python_type, ...)
41
+ else:
42
+ fields[f.name] = (Optional[python_type], None)
43
+ suffix = "Update" if all_optional else "Create"
44
+ return create_model(f"{schema.name}{suffix}", **fields)
45
+
46
+
47
+ class FlashAPI:
48
+ """FastAPI adapter — generates a full CRUD API from models."""
49
+
50
+ def __init__(
51
+ self,
52
+ models: list,
53
+ *,
54
+ engine=None,
55
+ database: str = "flashapi.db",
56
+ docs: bool = True,
57
+ formatter: Optional[Callable] = None,
58
+ ):
59
+ self._app = FastAPI(
60
+ title="FlashAPI",
61
+ description="Define your models. FlashAPI does the rest.",
62
+ docs_url="/docs" if docs else None,
63
+ redoc_url="/redoc" if docs else None,
64
+ )
65
+ self._engine = engine
66
+ self._session_factory = None
67
+ if engine is not None:
68
+ from sqlalchemy.orm import sessionmaker
69
+ self._session_factory = sessionmaker(bind=engine)
70
+ self._auto_storage = AutoStorage(database) if engine is None else None
71
+ self._formatter = formatter
72
+ self._schemas: list[ModelSchema] = []
73
+ self._storages: dict[str, Any] = {}
74
+
75
+ for model_entry in models:
76
+ self._prepare_model(model_entry)
77
+
78
+ resolve_relations(self._schemas)
79
+
80
+ for schema in self._schemas:
81
+ self._create_routes(schema)
82
+
83
+ self._register_relations()
84
+
85
+ def _prepare_model(self, model_entry) -> None:
86
+ if isinstance(model_entry, Model):
87
+ wrapper = model_entry
88
+ else:
89
+ wrapper = Model(model_entry)
90
+
91
+ schema = inspect_model(wrapper.model_class, plural=wrapper.plural)
92
+ schema.permissions = wrapper.permissions
93
+
94
+ is_sa = hasattr(wrapper.model_class, "__table__") and hasattr(wrapper.model_class, "__tablename__")
95
+
96
+ if is_sa and self._session_factory is not None:
97
+ storage = SQLAlchemyStorage(self._session_factory, wrapper.model_class)
98
+ else:
99
+ self._auto_storage.ensure_table(schema)
100
+ storage = self._auto_storage
101
+
102
+ self._storages[schema.plural] = storage
103
+ self._schemas.append(schema)
104
+
105
+ def _register_relations(self) -> None:
106
+ parent_to_children = resolve_relations(self._schemas)
107
+ formatter = self._formatter
108
+
109
+ for parent_plural, relations in parent_to_children.items():
110
+ for relation in relations:
111
+ self._add_nested_list_route(
112
+ parent_plural=parent_plural,
113
+ child_plural=relation.target_plural,
114
+ foreign_key=relation.foreign_key,
115
+ parent_storage=self._storages.get(parent_plural),
116
+ child_storage=self._storages.get(relation.target_plural),
117
+ formatter=formatter,
118
+ )
119
+
120
+ def _create_routes(self, schema: ModelSchema) -> None:
121
+ table = schema.plural
122
+ field_names = {f.name for f in schema.fields if not f.primary_key}
123
+ formatter = self._formatter
124
+ storage = self._storages[table]
125
+ expandable = find_expandable_fields(schema)
126
+ create_model_cls = _build_pydantic_model(schema)
127
+ update_model_cls = _build_pydantic_model(schema, all_optional=True)
128
+
129
+ if "list" in schema.permissions:
130
+ self._add_list_route(table, field_names, formatter, storage, schema.name, expandable)
131
+
132
+ if "read" in schema.permissions:
133
+ self._add_read_route(table, formatter, storage, schema.name, expandable)
134
+
135
+ if "create" in schema.permissions:
136
+ self._add_create_route(table, field_names, formatter, storage, schema.name, create_model_cls)
137
+
138
+ if "update" in schema.permissions:
139
+ self._add_update_route(table, field_names, formatter, storage, schema.name, update_model_cls)
140
+
141
+ if "delete" in schema.permissions:
142
+ self._add_delete_route(table, storage, schema.name)
143
+
144
+ def _add_list_route(self, table, field_names, formatter, storage, tag, expandable):
145
+ @self._app.get(f"/{table}", tags=[tag], name=f"{table}_list")
146
+ async def route(
147
+ request: Request,
148
+ page: int = Query(1, ge=1),
149
+ page_size: int = Query(20, ge=1, le=100),
150
+ sort: Optional[str] = None,
151
+ search: Optional[str] = None,
152
+ expand: Optional[str] = None,
153
+ ):
154
+ items = storage.list_all(table)
155
+ params = dict(request.query_params)
156
+ items = apply_filters(items, params, field_names)
157
+ items = apply_search(items, search, field_names)
158
+ items = apply_sorting(items, sort, field_names)
159
+ page_items, total = paginate(items, page, page_size)
160
+
161
+ if expand:
162
+ page_items = self._expand_items(page_items, expand, expandable)
163
+
164
+ return create_list_response(page_items, total, page, page_size, formatter)
165
+
166
+ def _add_read_route(self, table, formatter, storage, tag, expandable):
167
+ @self._app.get(f"/{table}/{{item_id}}", tags=[tag], name=f"{table}_read")
168
+ async def route(item_id: int, expand: Optional[str] = None):
169
+ item = storage.get(table, item_id)
170
+ if item is None:
171
+ raise HTTPException(status_code=404, detail="Not found")
172
+
173
+ if expand:
174
+ item = self._expand_items([item], expand, expandable)[0]
175
+
176
+ return create_item_response(item, formatter)
177
+
178
+ def _add_create_route(self, table, field_names, formatter, storage, tag, body_model):
179
+ @self._app.post(f"/{table}", status_code=201, tags=[tag], name=f"{table}_create")
180
+ async def route(body: body_model):
181
+ data = {k: v for k, v in body.model_dump(exclude_unset=True).items() if k in field_names}
182
+ item = storage.create(table, data)
183
+ return create_item_response(item, formatter)
184
+
185
+ def _add_update_route(self, table, field_names, formatter, storage, tag, body_model):
186
+ @self._app.put(f"/{table}/{{item_id}}", tags=[tag], name=f"{table}_update")
187
+ async def route(item_id: int, body: body_model):
188
+ data = {k: v for k, v in body.model_dump(exclude_unset=True).items() if k in field_names}
189
+ item = storage.update(table, item_id, data)
190
+ if item is None:
191
+ raise HTTPException(status_code=404, detail="Not found")
192
+ return create_item_response(item, formatter)
193
+
194
+ def _add_delete_route(self, table, storage, tag):
195
+ @self._app.delete(f"/{table}/{{item_id}}", status_code=204, tags=[tag], name=f"{table}_delete")
196
+ async def route(item_id: int):
197
+ deleted = storage.delete(table, item_id)
198
+ if not deleted:
199
+ raise HTTPException(status_code=404, detail="Not found")
200
+
201
+ def _add_nested_list_route(self, parent_plural, child_plural, foreign_key, parent_storage, child_storage, formatter):
202
+ @self._app.get(
203
+ f"/{parent_plural}/{{parent_id}}/{child_plural}",
204
+ tags=[parent_plural.title()],
205
+ name=f"{parent_plural}_{child_plural}_nested",
206
+ )
207
+ async def route(
208
+ parent_id: int,
209
+ page: int = Query(1, ge=1),
210
+ page_size: int = Query(20, ge=1, le=100),
211
+ sort: Optional[str] = None,
212
+ search: Optional[str] = None,
213
+ ):
214
+ parent = parent_storage.get(parent_plural, parent_id)
215
+ if parent is None:
216
+ raise HTTPException(status_code=404, detail="Parent not found")
217
+
218
+ all_items = child_storage.list_all(child_plural)
219
+ items = [i for i in all_items if i.get(foreign_key) == parent_id]
220
+
221
+ child_fields = {k for item in items for k in item.keys() if k != "id"}
222
+ if search:
223
+ items = apply_search(items, search, child_fields)
224
+ if sort:
225
+ items = apply_sorting(items, sort, child_fields)
226
+
227
+ page_items, total = paginate(items, page, page_size)
228
+ return create_list_response(page_items, total, page, page_size, formatter)
229
+
230
+ def _expand_items(self, items, expand_param, expandable):
231
+ expand_fields = [f.strip() for f in expand_param.split(",")]
232
+ expanded_items = []
233
+
234
+ for item in items:
235
+ item_copy = dict(item)
236
+ for field_name in expand_fields:
237
+ if field_name in expandable:
238
+ target_plural = expandable[field_name]
239
+ target_storage = self._storages.get(target_plural)
240
+ if target_storage is None:
241
+ continue
242
+ fk_field = f"{field_name}_id"
243
+ fk_value = item_copy.get(fk_field)
244
+ if fk_value is not None:
245
+ related = target_storage.get(target_plural, fk_value)
246
+ if related:
247
+ item_copy[field_name] = related
248
+ expanded_items.append(item_copy)
249
+
250
+ return expanded_items
251
+
252
+ def get(self, path: str, *, tag: str = "Custom", summary: str = "", **kwargs):
253
+ """Register a custom GET route — appears in Swagger docs."""
254
+ def decorator(func):
255
+ self._app.get(path, tags=[tag], summary=summary or f"GET {path}", **kwargs)(func)
256
+ return func
257
+ return decorator
258
+
259
+ def post(self, path: str, *, tag: str = "Custom", summary: str = "", **kwargs):
260
+ """Register a custom POST route — appears in Swagger docs."""
261
+ def decorator(func):
262
+ self._app.post(path, tags=[tag], summary=summary or f"POST {path}", **kwargs)(func)
263
+ return func
264
+ return decorator
265
+
266
+ def put(self, path: str, *, tag: str = "Custom", summary: str = "", **kwargs):
267
+ """Register a custom PUT route — appears in Swagger docs."""
268
+ def decorator(func):
269
+ self._app.put(path, tags=[tag], summary=summary or f"PUT {path}", **kwargs)(func)
270
+ return func
271
+ return decorator
272
+
273
+ def delete(self, path: str, *, tag: str = "Custom", summary: str = "", **kwargs):
274
+ """Register a custom DELETE route — appears in Swagger docs."""
275
+ def decorator(func):
276
+ self._app.delete(path, tags=[tag], summary=summary or f"DELETE {path}", **kwargs)(func)
277
+ return func
278
+ return decorator
279
+
280
+ def patch(self, path: str, *, tag: str = "Custom", summary: str = "", **kwargs):
281
+ """Register a custom PATCH route — appears in Swagger docs."""
282
+ def decorator(func):
283
+ self._app.patch(path, tags=[tag], summary=summary or f"PATCH {path}", **kwargs)(func)
284
+ return func
285
+ return decorator
286
+
287
+ @property
288
+ def app(self):
289
+ return self._app
290
+
291
+ def run(self, host: str = "0.0.0.0", port: int = 8000, **kwargs):
292
+ import uvicorn
293
+ uvicorn.run(self._app, host=host, port=port, **kwargs)