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.
@@ -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,3 @@
1
+ from flashapi.core.schema import Model, ModelSchema, FieldSchema, FieldType, RelationSchema
2
+
3
+ __all__ = ["Model", "ModelSchema", "FieldSchema", "FieldType", "RelationSchema"]
@@ -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)