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
|
@@ -0,0 +1,232 @@
|
|
|
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.relations import resolve_relations, find_expandable_fields
|
|
8
|
+
from flashapi.core.custom_routes import (
|
|
9
|
+
CustomRoute, custom_routes_to_openapi_paths, discover_flask_views,
|
|
10
|
+
)
|
|
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
|
+
from flashapi.docs.openapi import generate_openapi_schema, get_swagger_html
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register_models(
|
|
19
|
+
app,
|
|
20
|
+
models: list[type | Model],
|
|
21
|
+
*,
|
|
22
|
+
engine=None,
|
|
23
|
+
custom_routes: list[CustomRoute] | None = None,
|
|
24
|
+
database: str = "flashapi.db",
|
|
25
|
+
docs: bool = True,
|
|
26
|
+
formatter: Callable | None = None,
|
|
27
|
+
):
|
|
28
|
+
"""Register models on an existing Flask app."""
|
|
29
|
+
from flask import Blueprint
|
|
30
|
+
|
|
31
|
+
session_factory = None
|
|
32
|
+
if engine is not None:
|
|
33
|
+
from sqlalchemy.orm import sessionmaker
|
|
34
|
+
session_factory = sessionmaker(bind=engine)
|
|
35
|
+
|
|
36
|
+
auto_storage = AutoStorage(database) if engine is None else None
|
|
37
|
+
blueprint = Blueprint("flashapi", __name__)
|
|
38
|
+
all_schemas: list[ModelSchema] = []
|
|
39
|
+
storages: dict[str, any] = {}
|
|
40
|
+
|
|
41
|
+
for model_entry in models:
|
|
42
|
+
if isinstance(model_entry, Model):
|
|
43
|
+
wrapper = model_entry
|
|
44
|
+
else:
|
|
45
|
+
wrapper = Model(model_entry)
|
|
46
|
+
|
|
47
|
+
schema = inspect_model(wrapper.model_class, plural=wrapper.plural)
|
|
48
|
+
schema.permissions = wrapper.permissions
|
|
49
|
+
|
|
50
|
+
is_sa = hasattr(wrapper.model_class, "__table__") and hasattr(wrapper.model_class, "__tablename__")
|
|
51
|
+
|
|
52
|
+
if is_sa and session_factory is not None:
|
|
53
|
+
storage = SQLAlchemyStorage(session_factory, wrapper.model_class)
|
|
54
|
+
else:
|
|
55
|
+
auto_storage.ensure_table(schema)
|
|
56
|
+
storage = auto_storage
|
|
57
|
+
|
|
58
|
+
storages[schema.plural] = storage
|
|
59
|
+
all_schemas.append(schema)
|
|
60
|
+
expandable = find_expandable_fields(schema)
|
|
61
|
+
_create_flask_routes(blueprint, schema, storage, formatter, expandable)
|
|
62
|
+
|
|
63
|
+
parent_to_children = resolve_relations(all_schemas)
|
|
64
|
+
for parent_plural, relations in parent_to_children.items():
|
|
65
|
+
for relation in relations:
|
|
66
|
+
_create_nested_route(
|
|
67
|
+
blueprint, parent_plural, relation.target_plural,
|
|
68
|
+
relation.foreign_key, storages.get(relation.target_plural, storage), formatter,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if docs:
|
|
72
|
+
_add_docs_routes(blueprint, all_schemas, custom_routes or [], flask_app=app)
|
|
73
|
+
|
|
74
|
+
app.register_blueprint(blueprint)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _add_docs_routes(blueprint, schemas: list[ModelSchema], custom_routes: list[CustomRoute], flask_app=None) -> None:
|
|
78
|
+
from flask import jsonify, Response
|
|
79
|
+
|
|
80
|
+
openapi_spec = generate_openapi_schema(schemas)
|
|
81
|
+
|
|
82
|
+
# Method 1: explicit CustomRoute objects
|
|
83
|
+
if custom_routes:
|
|
84
|
+
custom_paths = custom_routes_to_openapi_paths(custom_routes)
|
|
85
|
+
openapi_spec["paths"].update(custom_paths)
|
|
86
|
+
|
|
87
|
+
# Method 2: auto-discover @api_doc decorated views (done lazily on first request)
|
|
88
|
+
_discovered = {"done": False}
|
|
89
|
+
|
|
90
|
+
@blueprint.route("/openapi.json", methods=["GET"], endpoint="flashapi_openapi")
|
|
91
|
+
def openapi_json():
|
|
92
|
+
if not _discovered["done"] and flask_app is not None:
|
|
93
|
+
discovered = discover_flask_views(flask_app)
|
|
94
|
+
openapi_spec["paths"].update(discovered)
|
|
95
|
+
_discovered["done"] = True
|
|
96
|
+
return jsonify(openapi_spec)
|
|
97
|
+
|
|
98
|
+
@blueprint.route("/docs", methods=["GET"], endpoint="flashapi_docs")
|
|
99
|
+
def docs_ui():
|
|
100
|
+
html = get_swagger_html(title="FlashAPI", openapi_url="/openapi.json")
|
|
101
|
+
return Response(html, content_type="text/html")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _create_nested_route(blueprint, parent_plural, child_plural, foreign_key, storage, formatter):
|
|
105
|
+
from flask import request, jsonify
|
|
106
|
+
|
|
107
|
+
@blueprint.route(
|
|
108
|
+
f"/{parent_plural}/<int:parent_id>/{child_plural}",
|
|
109
|
+
methods=["GET"],
|
|
110
|
+
endpoint=f"{parent_plural}_{child_plural}_nested",
|
|
111
|
+
)
|
|
112
|
+
def nested_list(parent_id, _pp=parent_plural, _cp=child_plural, _fk=foreign_key):
|
|
113
|
+
parent = storage.get(_pp, parent_id)
|
|
114
|
+
if parent is None:
|
|
115
|
+
return jsonify({"error": "Parent not found"}), 404
|
|
116
|
+
|
|
117
|
+
all_items = storage.list_all(_cp)
|
|
118
|
+
items = [i for i in all_items if i.get(_fk) == parent_id]
|
|
119
|
+
|
|
120
|
+
params = dict(request.args)
|
|
121
|
+
page = int(params.get("page", 1))
|
|
122
|
+
page_size = int(params.get("page_size", 20))
|
|
123
|
+
sort = params.get("sort")
|
|
124
|
+
search = params.get("search")
|
|
125
|
+
|
|
126
|
+
child_fields = {k for item in items for k in item.keys() if k != "id"}
|
|
127
|
+
if search:
|
|
128
|
+
items = apply_search(items, search, child_fields)
|
|
129
|
+
if sort:
|
|
130
|
+
items = apply_sorting(items, sort, child_fields)
|
|
131
|
+
|
|
132
|
+
page_items, total = paginate(items, page, page_size)
|
|
133
|
+
return jsonify(create_list_response(page_items, total, page, page_size, formatter))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _expand_items(items, expand_param, expandable, storage):
|
|
137
|
+
expand_fields = [f.strip() for f in expand_param.split(",")]
|
|
138
|
+
expanded_items = []
|
|
139
|
+
|
|
140
|
+
for item in items:
|
|
141
|
+
item_copy = dict(item)
|
|
142
|
+
for field_name in expand_fields:
|
|
143
|
+
if field_name in expandable:
|
|
144
|
+
fk_field = f"{field_name}_id"
|
|
145
|
+
fk_value = item_copy.get(fk_field)
|
|
146
|
+
if fk_value is not None:
|
|
147
|
+
related = storage.get(expandable[field_name], fk_value)
|
|
148
|
+
if related:
|
|
149
|
+
item_copy[field_name] = related
|
|
150
|
+
expanded_items.append(item_copy)
|
|
151
|
+
|
|
152
|
+
return expanded_items
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _create_flask_routes(
|
|
156
|
+
blueprint,
|
|
157
|
+
schema: ModelSchema,
|
|
158
|
+
storage: AutoStorage,
|
|
159
|
+
formatter: Callable | None,
|
|
160
|
+
expandable: dict,
|
|
161
|
+
) -> None:
|
|
162
|
+
from flask import request, jsonify
|
|
163
|
+
|
|
164
|
+
table = schema.plural
|
|
165
|
+
field_names = {f.name for f in schema.fields if not f.primary_key}
|
|
166
|
+
|
|
167
|
+
if "list" in schema.permissions:
|
|
168
|
+
@blueprint.route(f"/{table}", methods=["GET"], endpoint=f"{table}_list")
|
|
169
|
+
def list_items(_table=table, _fields=field_names, _exp=expandable):
|
|
170
|
+
items = storage.list_all(_table)
|
|
171
|
+
params = dict(request.args)
|
|
172
|
+
try:
|
|
173
|
+
page = max(1, int(params.get("page", 1)))
|
|
174
|
+
page_size = max(1, min(100, int(params.get("page_size", 20))))
|
|
175
|
+
except (ValueError, TypeError):
|
|
176
|
+
return jsonify({"error": "Invalid page or page_size parameter"}), 400
|
|
177
|
+
sort = params.get("sort")
|
|
178
|
+
search = params.get("search")
|
|
179
|
+
expand = params.get("expand")
|
|
180
|
+
|
|
181
|
+
items = apply_filters(items, params, _fields)
|
|
182
|
+
items = apply_search(items, search, _fields)
|
|
183
|
+
items = apply_sorting(items, sort, _fields)
|
|
184
|
+
page_items, total = paginate(items, page, page_size)
|
|
185
|
+
|
|
186
|
+
if expand:
|
|
187
|
+
page_items = _expand_items(page_items, expand, _exp, storage)
|
|
188
|
+
|
|
189
|
+
return jsonify(create_list_response(page_items, total, page, page_size, formatter))
|
|
190
|
+
|
|
191
|
+
if "read" in schema.permissions:
|
|
192
|
+
@blueprint.route(f"/{table}/<int:item_id>", methods=["GET"], endpoint=f"{table}_get")
|
|
193
|
+
def get_item(item_id, _table=table, _exp=expandable):
|
|
194
|
+
item = storage.get(_table, item_id)
|
|
195
|
+
if item is None:
|
|
196
|
+
return jsonify({"error": "Not found"}), 404
|
|
197
|
+
|
|
198
|
+
expand = request.args.get("expand")
|
|
199
|
+
if expand:
|
|
200
|
+
item = _expand_items([item], expand, _exp, storage)[0]
|
|
201
|
+
|
|
202
|
+
return jsonify(create_item_response(item, formatter))
|
|
203
|
+
|
|
204
|
+
if "create" in schema.permissions:
|
|
205
|
+
@blueprint.route(f"/{table}", methods=["POST"], endpoint=f"{table}_create")
|
|
206
|
+
def create_item(_table=table, _fields=field_names):
|
|
207
|
+
body = request.get_json(silent=True)
|
|
208
|
+
if not body:
|
|
209
|
+
return jsonify({"error": "Request body is required"}), 400
|
|
210
|
+
data = {k: v for k, v in body.items() if k in _fields}
|
|
211
|
+
item = storage.create(_table, data)
|
|
212
|
+
return jsonify(create_item_response(item, formatter)), 201
|
|
213
|
+
|
|
214
|
+
if "update" in schema.permissions:
|
|
215
|
+
@blueprint.route(f"/{table}/<int:item_id>", methods=["PUT"], endpoint=f"{table}_update")
|
|
216
|
+
def update_item(item_id, _table=table, _fields=field_names):
|
|
217
|
+
body = request.get_json(silent=True)
|
|
218
|
+
if not body:
|
|
219
|
+
return jsonify({"error": "Request body is required"}), 400
|
|
220
|
+
data = {k: v for k, v in body.items() if k in _fields}
|
|
221
|
+
item = storage.update(_table, item_id, data)
|
|
222
|
+
if item is None:
|
|
223
|
+
return jsonify({"error": "Not found"}), 404
|
|
224
|
+
return jsonify(create_item_response(item, formatter))
|
|
225
|
+
|
|
226
|
+
if "delete" in schema.permissions:
|
|
227
|
+
@blueprint.route(f"/{table}/<int:item_id>", methods=["DELETE"], endpoint=f"{table}_delete")
|
|
228
|
+
def delete_item(item_id, _table=table):
|
|
229
|
+
deleted = storage.delete(_table, item_id)
|
|
230
|
+
if not deleted:
|
|
231
|
+
return jsonify({"error": "Not found"}), 404
|
|
232
|
+
return "", 204
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Decorator and registry for custom routes appearing in FlashAPI's Swagger docs.
|
|
2
|
+
|
|
3
|
+
Usage — place @api_doc on your view:
|
|
4
|
+
|
|
5
|
+
@api_doc(tag="Orders", summary="Checkout", body={"cart_id": "int"})
|
|
6
|
+
def checkout(request):
|
|
7
|
+
...
|
|
8
|
+
|
|
9
|
+
FlashAPI auto-discovers these views and adds them to the OpenAPI spec.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
TYPE_MAP = {
|
|
19
|
+
"string": {"type": "string"},
|
|
20
|
+
"str": {"type": "string"},
|
|
21
|
+
"integer": {"type": "integer"},
|
|
22
|
+
"int": {"type": "integer"},
|
|
23
|
+
"number": {"type": "number"},
|
|
24
|
+
"float": {"type": "number"},
|
|
25
|
+
"boolean": {"type": "boolean"},
|
|
26
|
+
"bool": {"type": "boolean"},
|
|
27
|
+
"array": {"type": "array", "items": {"type": "string"}},
|
|
28
|
+
"list": {"type": "array", "items": {"type": "string"}},
|
|
29
|
+
"object": {"type": "object"},
|
|
30
|
+
"dict": {"type": "object"},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def api_doc(
|
|
35
|
+
*,
|
|
36
|
+
tag: str = "Custom",
|
|
37
|
+
summary: str = "",
|
|
38
|
+
methods: list[str] | None = None,
|
|
39
|
+
body: dict[str, str] | None = None,
|
|
40
|
+
body_required: list[str] | None = None,
|
|
41
|
+
params: dict[str, str] | None = None,
|
|
42
|
+
response: dict[str, Any] | None = None,
|
|
43
|
+
):
|
|
44
|
+
"""Decorator that marks a view for inclusion in FlashAPI's Swagger docs.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
tag: Group name in Swagger UI.
|
|
48
|
+
summary: One-line description of the endpoint.
|
|
49
|
+
methods: HTTP methods (auto-detected if not specified).
|
|
50
|
+
body: Request body fields as {name: type}. Types: str, int, float, bool, array, object.
|
|
51
|
+
body_required: List of required field names in body.
|
|
52
|
+
params: Query parameters as {name: type}.
|
|
53
|
+
response: Response schema (raw OpenAPI schema dict).
|
|
54
|
+
"""
|
|
55
|
+
def decorator(func):
|
|
56
|
+
func._flashapi_doc = {
|
|
57
|
+
"tag": tag,
|
|
58
|
+
"summary": summary,
|
|
59
|
+
"methods": [m.lower() for m in methods] if methods else None,
|
|
60
|
+
"body": body,
|
|
61
|
+
"body_required": body_required or [],
|
|
62
|
+
"params": params,
|
|
63
|
+
"response": response,
|
|
64
|
+
}
|
|
65
|
+
return func
|
|
66
|
+
return decorator
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _extract_doc_from_view(view_func) -> dict | None:
|
|
70
|
+
"""Extract _flashapi_doc metadata from a view function (unwrapping decorators)."""
|
|
71
|
+
func = view_func
|
|
72
|
+
# Unwrap csrf_exempt and other decorators
|
|
73
|
+
while func is not None:
|
|
74
|
+
if hasattr(func, "_flashapi_doc"):
|
|
75
|
+
return func._flashapi_doc
|
|
76
|
+
func = getattr(func, "__wrapped__", None)
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _build_openapi_operation(doc: dict, method: str) -> dict[str, Any]:
|
|
81
|
+
"""Build an OpenAPI operation from @api_doc metadata."""
|
|
82
|
+
operation: dict[str, Any] = {
|
|
83
|
+
"tags": [doc["tag"]],
|
|
84
|
+
"summary": doc["summary"] or f"{method.upper()} endpoint",
|
|
85
|
+
"responses": {
|
|
86
|
+
"200": {"description": "Success"}
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if doc.get("response"):
|
|
91
|
+
operation["responses"]["200"]["content"] = {
|
|
92
|
+
"application/json": {"schema": doc["response"]}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if doc.get("params"):
|
|
96
|
+
operation["parameters"] = []
|
|
97
|
+
for name, ptype in doc["params"].items():
|
|
98
|
+
operation["parameters"].append({
|
|
99
|
+
"name": name,
|
|
100
|
+
"in": "query",
|
|
101
|
+
"required": False,
|
|
102
|
+
"schema": TYPE_MAP.get(ptype, {"type": "string"}),
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
if doc.get("body") and method in ("post", "put", "patch"):
|
|
106
|
+
properties = {}
|
|
107
|
+
for fname, ftype in doc["body"].items():
|
|
108
|
+
properties[fname] = TYPE_MAP.get(ftype, {"type": "string"})
|
|
109
|
+
body_schema: dict[str, Any] = {"type": "object", "properties": properties}
|
|
110
|
+
if doc.get("body_required"):
|
|
111
|
+
body_schema["required"] = doc["body_required"]
|
|
112
|
+
operation["requestBody"] = {
|
|
113
|
+
"required": True,
|
|
114
|
+
"content": {"application/json": {"schema": body_schema}},
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return operation
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def discover_django_views(url_patterns, trailing_slash: bool = True) -> dict[str, dict]:
|
|
121
|
+
"""Scan Django URL patterns for @api_doc-decorated views and build OpenAPI paths."""
|
|
122
|
+
paths: dict[str, dict] = {}
|
|
123
|
+
|
|
124
|
+
for pattern in url_patterns:
|
|
125
|
+
callback = getattr(pattern, "callback", None)
|
|
126
|
+
if callback is None:
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
doc = _extract_doc_from_view(callback)
|
|
130
|
+
if doc is None:
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
# Build path from Django pattern
|
|
134
|
+
path_str = "/" + str(pattern.pattern)
|
|
135
|
+
if trailing_slash and not path_str.endswith("/"):
|
|
136
|
+
path_str += "/"
|
|
137
|
+
|
|
138
|
+
# Determine methods
|
|
139
|
+
methods = doc["methods"]
|
|
140
|
+
if not methods:
|
|
141
|
+
methods = ["get"]
|
|
142
|
+
|
|
143
|
+
if path_str not in paths:
|
|
144
|
+
paths[path_str] = {}
|
|
145
|
+
|
|
146
|
+
for method in methods:
|
|
147
|
+
paths[path_str][method] = _build_openapi_operation(doc, method)
|
|
148
|
+
|
|
149
|
+
return paths
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def discover_flask_views(app) -> dict[str, dict]:
|
|
153
|
+
"""Scan a Flask app for @api_doc-decorated views and build OpenAPI paths."""
|
|
154
|
+
paths: dict[str, dict] = {}
|
|
155
|
+
|
|
156
|
+
for rule in app.url_map.iter_rules():
|
|
157
|
+
view_func = app.view_functions.get(rule.endpoint)
|
|
158
|
+
if view_func is None:
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
doc = _extract_doc_from_view(view_func)
|
|
162
|
+
if doc is None:
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
# Convert Flask rule to OpenAPI path: /items/<int:item_id> → /items/{item_id}
|
|
166
|
+
path_str = rule.rule
|
|
167
|
+
import re
|
|
168
|
+
path_str = re.sub(r"<(?:\w+:)?(\w+)>", r"{\1}", path_str)
|
|
169
|
+
|
|
170
|
+
# Determine methods
|
|
171
|
+
methods = doc["methods"]
|
|
172
|
+
if not methods:
|
|
173
|
+
rule_methods = [m.lower() for m in rule.methods if m not in ("HEAD", "OPTIONS")]
|
|
174
|
+
methods = rule_methods or ["get"]
|
|
175
|
+
|
|
176
|
+
if path_str not in paths:
|
|
177
|
+
paths[path_str] = {}
|
|
178
|
+
|
|
179
|
+
for method in methods:
|
|
180
|
+
paths[path_str][method] = _build_openapi_operation(doc, method)
|
|
181
|
+
|
|
182
|
+
return paths
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# Keep backward compat with CustomRoute for users who prefer explicit declaration
|
|
186
|
+
@dataclass
|
|
187
|
+
class RouteParam:
|
|
188
|
+
name: str
|
|
189
|
+
location: str = "query"
|
|
190
|
+
type: str = "string"
|
|
191
|
+
required: bool = False
|
|
192
|
+
description: str = ""
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass
|
|
196
|
+
class RouteBody:
|
|
197
|
+
fields: dict[str, str] = field(default_factory=dict)
|
|
198
|
+
required_fields: list[str] = field(default_factory=list)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass
|
|
202
|
+
class CustomRoute:
|
|
203
|
+
path: str
|
|
204
|
+
method: str = "get"
|
|
205
|
+
summary: str = ""
|
|
206
|
+
tag: str = "Custom"
|
|
207
|
+
parameters: list[RouteParam] = field(default_factory=list)
|
|
208
|
+
body: RouteBody | None = None
|
|
209
|
+
response_description: str = "Success"
|
|
210
|
+
response_schema: dict[str, Any] | None = None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def custom_routes_to_openapi_paths(routes: list[CustomRoute], trailing_slash: bool = False) -> dict:
|
|
214
|
+
"""Convert a list of CustomRoute to OpenAPI path entries."""
|
|
215
|
+
paths: dict[str, dict] = {}
|
|
216
|
+
suffix = "/" if trailing_slash else ""
|
|
217
|
+
|
|
218
|
+
for route in routes:
|
|
219
|
+
path_key = route.path.rstrip("/") + suffix if trailing_slash else route.path
|
|
220
|
+
if path_key not in paths:
|
|
221
|
+
paths[path_key] = {}
|
|
222
|
+
|
|
223
|
+
operation: dict[str, Any] = {
|
|
224
|
+
"tags": [route.tag],
|
|
225
|
+
"summary": route.summary or f"{route.method.upper()} {route.path}",
|
|
226
|
+
"responses": {
|
|
227
|
+
"200": {"description": route.response_description}
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if route.response_schema:
|
|
232
|
+
operation["responses"]["200"]["content"] = {
|
|
233
|
+
"application/json": {"schema": route.response_schema}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if route.parameters:
|
|
237
|
+
operation["parameters"] = []
|
|
238
|
+
for param in route.parameters:
|
|
239
|
+
operation["parameters"].append({
|
|
240
|
+
"name": param.name,
|
|
241
|
+
"in": param.location,
|
|
242
|
+
"required": param.required,
|
|
243
|
+
"description": param.description,
|
|
244
|
+
"schema": TYPE_MAP.get(param.type, {"type": "string"}),
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
if route.body:
|
|
248
|
+
properties = {}
|
|
249
|
+
for fname, ftype in route.body.fields.items():
|
|
250
|
+
properties[fname] = TYPE_MAP.get(ftype, {"type": "string"})
|
|
251
|
+
body_schema: dict[str, Any] = {"type": "object", "properties": properties}
|
|
252
|
+
if route.body.required_fields:
|
|
253
|
+
body_schema["required"] = route.body.required_fields
|
|
254
|
+
operation["requestBody"] = {
|
|
255
|
+
"required": True,
|
|
256
|
+
"content": {"application/json": {"schema": body_schema}},
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
paths[path_key][route.method.lower()] = operation
|
|
260
|
+
|
|
261
|
+
return paths
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Pluralization rules supporting English and French model names.
|
|
2
|
+
|
|
3
|
+
Strategy:
|
|
4
|
+
- Irregulars dict handles all known exceptions for both languages.
|
|
5
|
+
- Rules are ordered to avoid cross-language conflicts.
|
|
6
|
+
- For genuinely ambiguous cases, users can override via Model(plural=...).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
IRREGULARS = {
|
|
10
|
+
# English irregulars
|
|
11
|
+
"person": "people",
|
|
12
|
+
"child": "children",
|
|
13
|
+
"mouse": "mice",
|
|
14
|
+
"goose": "geese",
|
|
15
|
+
"man": "men",
|
|
16
|
+
"woman": "women",
|
|
17
|
+
"tooth": "teeth",
|
|
18
|
+
"foot": "feet",
|
|
19
|
+
"datum": "data",
|
|
20
|
+
"index": "indices",
|
|
21
|
+
"leaf": "leaves",
|
|
22
|
+
"knife": "knives",
|
|
23
|
+
"wife": "wives",
|
|
24
|
+
"life": "lives",
|
|
25
|
+
"shelf": "shelves",
|
|
26
|
+
"self": "selves",
|
|
27
|
+
"half": "halves",
|
|
28
|
+
"wolf": "wolves",
|
|
29
|
+
# English words ending in -s/-x/-z that take -es (not invariable)
|
|
30
|
+
"bus": "buses",
|
|
31
|
+
"box": "boxes",
|
|
32
|
+
"fox": "foxes",
|
|
33
|
+
"buzz": "buzzes",
|
|
34
|
+
"quiz": "quizzes",
|
|
35
|
+
"fez": "fezzes",
|
|
36
|
+
# French irregulars
|
|
37
|
+
"travail": "travaux",
|
|
38
|
+
"journal": "journaux",
|
|
39
|
+
"oeil": "yeux",
|
|
40
|
+
"monsieur": "messieurs",
|
|
41
|
+
"madame": "mesdames",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def pluralize(word: str) -> str:
|
|
46
|
+
lower = word.lower()
|
|
47
|
+
|
|
48
|
+
if lower in IRREGULARS:
|
|
49
|
+
return IRREGULARS[lower]
|
|
50
|
+
|
|
51
|
+
# --- English: -ss, -sh, -ch → +es (must check before -s invariable rule) ---
|
|
52
|
+
# en: class→classes, dish→dishes, match→matches
|
|
53
|
+
if lower.endswith(("ss", "sh", "ch")):
|
|
54
|
+
return lower + "es"
|
|
55
|
+
|
|
56
|
+
# --- Invariable endings (both languages) ---
|
|
57
|
+
# -s, -x, -z → no change (fr: temps, voix, nez / en: species)
|
|
58
|
+
if lower.endswith(("s", "x", "z")):
|
|
59
|
+
return lower
|
|
60
|
+
|
|
61
|
+
# --- French rules ---
|
|
62
|
+
|
|
63
|
+
# -eau, -au, -eu → +x (fr: niveau→niveaux, jeu→jeux, noyau→noyaux)
|
|
64
|
+
if lower.endswith(("eau", "au", "eu")):
|
|
65
|
+
return lower + "x"
|
|
66
|
+
|
|
67
|
+
# -al → -aux (fr: animal→animaux, journal→journaux)
|
|
68
|
+
# English exceptions (festival, carnival) are rare model names;
|
|
69
|
+
# if needed, add them to IRREGULARS or use Model(plural=...)
|
|
70
|
+
if lower.endswith("al"):
|
|
71
|
+
return lower[:-2] + "aux"
|
|
72
|
+
|
|
73
|
+
# consonant + y → -ies (en: category→categories, city→cities)
|
|
74
|
+
if lower.endswith("y") and len(lower) >= 2 and lower[-2] not in "aeiou":
|
|
75
|
+
return lower[:-1] + "ies"
|
|
76
|
+
|
|
77
|
+
# --- Default: +s (works for both languages) ---
|
|
78
|
+
# fr: maison→maisons, eleve→eleves, enseignant→enseignants
|
|
79
|
+
# en: book→books, user→users, article→articles
|
|
80
|
+
return lower + "s"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Relation resolution between registered models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from flashapi.core.schema import ModelSchema, RelationSchema
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def resolve_relations(schemas: list[ModelSchema]) -> dict[str, list[RelationSchema]]:
|
|
9
|
+
"""
|
|
10
|
+
Detect relations between models based on field names ending with _id.
|
|
11
|
+
Returns a mapping: parent_plural -> list of child relations.
|
|
12
|
+
|
|
13
|
+
Example: Book has author_id → Author is parent, Book is child.
|
|
14
|
+
So "authors" -> [RelationSchema(type="one_to_many", target="books")]
|
|
15
|
+
"""
|
|
16
|
+
schema_map = {s.name.lower(): s for s in schemas}
|
|
17
|
+
|
|
18
|
+
parent_to_children: dict[str, list[RelationSchema]] = {}
|
|
19
|
+
|
|
20
|
+
for schema in schemas:
|
|
21
|
+
for field in schema.fields:
|
|
22
|
+
if field.name.endswith("_id") and not field.primary_key:
|
|
23
|
+
ref_name = field.name[:-3] # "author_id" → "author"
|
|
24
|
+
|
|
25
|
+
target_schema = schema_map.get(ref_name)
|
|
26
|
+
if target_schema is None:
|
|
27
|
+
continue
|
|
28
|
+
|
|
29
|
+
relation = RelationSchema(
|
|
30
|
+
type="one_to_many",
|
|
31
|
+
target=schema.plural,
|
|
32
|
+
target_plural=schema.plural,
|
|
33
|
+
foreign_key=field.name,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
parent_plural = target_schema.plural
|
|
37
|
+
if parent_plural not in parent_to_children:
|
|
38
|
+
parent_to_children[parent_plural] = []
|
|
39
|
+
parent_to_children[parent_plural].append(relation)
|
|
40
|
+
|
|
41
|
+
field.relation = RelationSchema(
|
|
42
|
+
type="many_to_one",
|
|
43
|
+
target=target_schema.name,
|
|
44
|
+
target_plural=target_schema.plural,
|
|
45
|
+
foreign_key=field.name,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return parent_to_children
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def find_expandable_fields(schema: ModelSchema) -> dict[str, str]:
|
|
52
|
+
"""
|
|
53
|
+
Return a mapping of expandable field names to their target plural.
|
|
54
|
+
Example: {"author": "authors"} for a Book model with author_id.
|
|
55
|
+
"""
|
|
56
|
+
expandable = {}
|
|
57
|
+
for field in schema.fields:
|
|
58
|
+
if field.relation and field.relation.type == "many_to_one":
|
|
59
|
+
ref_name = field.name[:-3] # "author_id" → "author"
|
|
60
|
+
expandable[ref_name] = field.relation.target_plural
|
|
61
|
+
return expandable
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Response formatting."""
|
|
2
|
+
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def default_formatter(data: list | dict, meta: dict | None = None) -> dict:
|
|
7
|
+
if isinstance(data, list):
|
|
8
|
+
response = {"data": data}
|
|
9
|
+
if meta:
|
|
10
|
+
response.update(meta)
|
|
11
|
+
return response
|
|
12
|
+
return {"data": data}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def create_list_response(
|
|
16
|
+
data: list[dict],
|
|
17
|
+
total: int,
|
|
18
|
+
page: int,
|
|
19
|
+
page_size: int,
|
|
20
|
+
formatter: Callable | None = None,
|
|
21
|
+
) -> dict:
|
|
22
|
+
pages = (total + page_size - 1) // page_size
|
|
23
|
+
meta = {"total": total, "page": page, "pages": pages, "page_size": page_size}
|
|
24
|
+
|
|
25
|
+
if formatter:
|
|
26
|
+
return formatter(data, meta)
|
|
27
|
+
return default_formatter(data, meta)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def create_item_response(data: dict, formatter: Callable | None = None) -> dict:
|
|
31
|
+
if formatter:
|
|
32
|
+
return formatter(data, None)
|
|
33
|
+
return default_formatter(data)
|