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 +7 -0
- flashapi/adapters/__init__.py +0 -0
- flashapi/adapters/base.py +12 -0
- flashapi/adapters/django.py +165 -0
- flashapi/adapters/fastapi.py +293 -0
- flashapi/adapters/flask.py +232 -0
- flashapi/core/__init__.py +3 -0
- flashapi/core/custom_routes.py +261 -0
- flashapi/core/pluralize.py +80 -0
- flashapi/core/relations.py +61 -0
- flashapi/core/response.py +33 -0
- flashapi/core/schema.py +83 -0
- flashapi/django.py +5 -0
- flashapi/docs/__init__.py +0 -0
- flashapi/docs/openapi.py +223 -0
- flashapi/fastapi.py +5 -0
- flashapi/features/__init__.py +6 -0
- flashapi/features/filtering.py +33 -0
- flashapi/features/pagination.py +20 -0
- flashapi/features/search.py +25 -0
- flashapi/features/sorting.py +21 -0
- flashapi/flask.py +5 -0
- flashapi/inspectors/__init__.py +3 -0
- flashapi/inspectors/base.py +11 -0
- flashapi/inspectors/dataclass.py +39 -0
- flashapi/inspectors/detect.py +48 -0
- flashapi/inspectors/django.py +83 -0
- flashapi/inspectors/pydantic.py +84 -0
- flashapi/inspectors/sqlalchemy.py +75 -0
- flashapi/py.typed +0 -0
- flashapi/storage/__init__.py +4 -0
- flashapi/storage/auto.py +106 -0
- flashapi/storage/base.py +26 -0
- flashapi/storage/orm.py +85 -0
- flashapi/storage/sqlalchemy.py +137 -0
- python_flashapi-0.1.0.dist-info/METADATA +259 -0
- python_flashapi-0.1.0.dist-info/RECORD +39 -0
- python_flashapi-0.1.0.dist-info/WHEEL +4 -0
- python_flashapi-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|