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/core/schema.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FieldType(Enum):
|
|
9
|
+
STRING = "string"
|
|
10
|
+
INTEGER = "integer"
|
|
11
|
+
FLOAT = "float"
|
|
12
|
+
BOOLEAN = "boolean"
|
|
13
|
+
DATE = "date"
|
|
14
|
+
DATETIME = "datetime"
|
|
15
|
+
TIME = "time"
|
|
16
|
+
UUID = "uuid"
|
|
17
|
+
JSON = "json"
|
|
18
|
+
TEXT = "text"
|
|
19
|
+
BINARY = "binary"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class RelationSchema:
|
|
24
|
+
type: str # "one_to_one", "many_to_one", "one_to_many", "many_to_many"
|
|
25
|
+
target: str
|
|
26
|
+
target_plural: str = ""
|
|
27
|
+
foreign_key: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class FieldSchema:
|
|
32
|
+
name: str
|
|
33
|
+
type: FieldType
|
|
34
|
+
required: bool = True
|
|
35
|
+
default: Any = None
|
|
36
|
+
constraints: dict = field(default_factory=dict)
|
|
37
|
+
primary_key: bool = False
|
|
38
|
+
auto_generated: bool = False
|
|
39
|
+
relation: RelationSchema | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ModelSchema:
|
|
44
|
+
name: str
|
|
45
|
+
plural: str
|
|
46
|
+
fields: list[FieldSchema]
|
|
47
|
+
permissions: list[str] = field(
|
|
48
|
+
default_factory=lambda: ["list", "read", "create", "update", "delete"]
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
ALL_OPERATIONS = ["list", "read", "create", "update", "delete"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Model:
|
|
56
|
+
"""Wrapper to configure how a model is exposed via FlashAPI."""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
model_class: type,
|
|
61
|
+
*,
|
|
62
|
+
readonly: bool = False,
|
|
63
|
+
exclude: list[str] | None = None,
|
|
64
|
+
only: list[str] | None = None,
|
|
65
|
+
plural: str | None = None,
|
|
66
|
+
):
|
|
67
|
+
self.model_class = model_class
|
|
68
|
+
self.plural = plural
|
|
69
|
+
self.permissions = self._resolve_permissions(readonly, exclude, only)
|
|
70
|
+
|
|
71
|
+
def _resolve_permissions(
|
|
72
|
+
self,
|
|
73
|
+
readonly: bool,
|
|
74
|
+
exclude: list[str] | None,
|
|
75
|
+
only: list[str] | None,
|
|
76
|
+
) -> list[str]:
|
|
77
|
+
if only:
|
|
78
|
+
return [op for op in only if op in ALL_OPERATIONS]
|
|
79
|
+
if readonly:
|
|
80
|
+
return ["list", "read"]
|
|
81
|
+
if exclude:
|
|
82
|
+
return [op for op in ALL_OPERATIONS if op not in exclude]
|
|
83
|
+
return list(ALL_OPERATIONS)
|
flashapi/django.py
ADDED
|
File without changes
|
flashapi/docs/openapi.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""OpenAPI schema generation and Swagger UI for Flask and Django."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from flashapi.core.schema import ModelSchema, FieldType
|
|
6
|
+
|
|
7
|
+
FIELD_TYPE_TO_OPENAPI = {
|
|
8
|
+
FieldType.STRING: {"type": "string"},
|
|
9
|
+
FieldType.INTEGER: {"type": "integer"},
|
|
10
|
+
FieldType.FLOAT: {"type": "number"},
|
|
11
|
+
FieldType.BOOLEAN: {"type": "boolean"},
|
|
12
|
+
FieldType.DATE: {"type": "string", "format": "date"},
|
|
13
|
+
FieldType.DATETIME: {"type": "string", "format": "date-time"},
|
|
14
|
+
FieldType.TIME: {"type": "string", "format": "time"},
|
|
15
|
+
FieldType.UUID: {"type": "string", "format": "uuid"},
|
|
16
|
+
FieldType.JSON: {"type": "object"},
|
|
17
|
+
FieldType.TEXT: {"type": "string"},
|
|
18
|
+
FieldType.BINARY: {"type": "string", "format": "binary"},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
SWAGGER_UI_HTML = """<!DOCTYPE html>
|
|
22
|
+
<html>
|
|
23
|
+
<head>
|
|
24
|
+
<title>{title} - Docs</title>
|
|
25
|
+
<meta charset="utf-8"/>
|
|
26
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
27
|
+
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
|
28
|
+
</head>
|
|
29
|
+
<body>
|
|
30
|
+
<div id="swagger-ui"></div>
|
|
31
|
+
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
32
|
+
<script>
|
|
33
|
+
SwaggerUIBundle({{
|
|
34
|
+
url: "{openapi_url}",
|
|
35
|
+
dom_id: '#swagger-ui',
|
|
36
|
+
presets: [
|
|
37
|
+
SwaggerUIBundle.presets.apis,
|
|
38
|
+
SwaggerUIBundle.SwaggerUIStandalonePreset
|
|
39
|
+
],
|
|
40
|
+
layout: "BaseLayout"
|
|
41
|
+
}})
|
|
42
|
+
</script>
|
|
43
|
+
</body>
|
|
44
|
+
</html>"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def generate_openapi_schema(
|
|
48
|
+
schemas: list[ModelSchema],
|
|
49
|
+
title: str = "FlashAPI",
|
|
50
|
+
version: str = "0.1.0",
|
|
51
|
+
description: str = "Define your models. FlashAPI does the rest.",
|
|
52
|
+
trailing_slash: bool = False,
|
|
53
|
+
) -> dict[str, Any]:
|
|
54
|
+
"""Generate a full OpenAPI 3.1.0 schema from model schemas."""
|
|
55
|
+
paths = {}
|
|
56
|
+
components_schemas = {}
|
|
57
|
+
|
|
58
|
+
for schema in schemas:
|
|
59
|
+
components_schemas[schema.name] = _build_model_schema(schema)
|
|
60
|
+
components_schemas[f"{schema.name}Create"] = _build_model_schema(schema, exclude_auto_pk=True)
|
|
61
|
+
model_paths = _build_paths(schema, trailing_slash=trailing_slash)
|
|
62
|
+
paths.update(model_paths)
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
"openapi": "3.1.0",
|
|
66
|
+
"info": {
|
|
67
|
+
"title": title,
|
|
68
|
+
"version": version,
|
|
69
|
+
"description": description,
|
|
70
|
+
},
|
|
71
|
+
"paths": paths,
|
|
72
|
+
"components": {
|
|
73
|
+
"schemas": components_schemas,
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _build_model_schema(schema: ModelSchema, *, exclude_auto_pk: bool = False) -> dict:
|
|
79
|
+
properties = {}
|
|
80
|
+
required = []
|
|
81
|
+
|
|
82
|
+
for field in schema.fields:
|
|
83
|
+
if exclude_auto_pk and field.primary_key and field.auto_generated:
|
|
84
|
+
continue
|
|
85
|
+
prop = dict(FIELD_TYPE_TO_OPENAPI.get(field.type, {"type": "string"}))
|
|
86
|
+
if field.constraints.get("max_length"):
|
|
87
|
+
prop["maxLength"] = field.constraints["max_length"]
|
|
88
|
+
if field.constraints.get("min_length"):
|
|
89
|
+
prop["minLength"] = field.constraints["min_length"]
|
|
90
|
+
if field.constraints.get("min_value") is not None:
|
|
91
|
+
prop["minimum"] = field.constraints["min_value"]
|
|
92
|
+
if field.constraints.get("max_value") is not None:
|
|
93
|
+
prop["maximum"] = field.constraints["max_value"]
|
|
94
|
+
properties[field.name] = prop
|
|
95
|
+
if field.required and not field.primary_key:
|
|
96
|
+
required.append(field.name)
|
|
97
|
+
|
|
98
|
+
result = {"type": "object", "properties": properties}
|
|
99
|
+
if required:
|
|
100
|
+
result["required"] = required
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _build_paths(schema: ModelSchema, trailing_slash: bool = False) -> dict:
|
|
105
|
+
paths = {}
|
|
106
|
+
table = schema.plural
|
|
107
|
+
tag = schema.name
|
|
108
|
+
suffix = "/" if trailing_slash else ""
|
|
109
|
+
|
|
110
|
+
collection_ops = {}
|
|
111
|
+
detail_ops = {}
|
|
112
|
+
|
|
113
|
+
if "list" in schema.permissions:
|
|
114
|
+
collection_ops["get"] = {
|
|
115
|
+
"tags": [tag],
|
|
116
|
+
"summary": f"List all {table}",
|
|
117
|
+
"parameters": [
|
|
118
|
+
{"name": "page", "in": "query", "schema": {"type": "integer", "default": 1}},
|
|
119
|
+
{"name": "page_size", "in": "query", "schema": {"type": "integer", "default": 20}},
|
|
120
|
+
{"name": "sort", "in": "query", "schema": {"type": "string"}},
|
|
121
|
+
{"name": "search", "in": "query", "schema": {"type": "string"}},
|
|
122
|
+
],
|
|
123
|
+
"responses": {
|
|
124
|
+
"200": {
|
|
125
|
+
"description": "Paginated list",
|
|
126
|
+
"content": {"application/json": {"schema": {
|
|
127
|
+
"type": "object",
|
|
128
|
+
"properties": {
|
|
129
|
+
"data": {"type": "array", "items": {"$ref": f"#/components/schemas/{schema.name}"}},
|
|
130
|
+
"total": {"type": "integer"},
|
|
131
|
+
"page": {"type": "integer"},
|
|
132
|
+
"pages": {"type": "integer"},
|
|
133
|
+
"page_size": {"type": "integer"},
|
|
134
|
+
}
|
|
135
|
+
}}}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if "create" in schema.permissions:
|
|
141
|
+
collection_ops["post"] = {
|
|
142
|
+
"tags": [tag],
|
|
143
|
+
"summary": f"Create a {schema.name.lower()}",
|
|
144
|
+
"requestBody": {
|
|
145
|
+
"required": True,
|
|
146
|
+
"content": {"application/json": {"schema": {"$ref": f"#/components/schemas/{schema.name}Create"}}}
|
|
147
|
+
},
|
|
148
|
+
"responses": {
|
|
149
|
+
"201": {
|
|
150
|
+
"description": "Created",
|
|
151
|
+
"content": {"application/json": {"schema": {
|
|
152
|
+
"type": "object",
|
|
153
|
+
"properties": {"data": {"$ref": f"#/components/schemas/{schema.name}"}}
|
|
154
|
+
}}}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if "read" in schema.permissions:
|
|
160
|
+
detail_ops["get"] = {
|
|
161
|
+
"tags": [tag],
|
|
162
|
+
"summary": f"Get a {schema.name.lower()} by ID",
|
|
163
|
+
"parameters": [
|
|
164
|
+
{"name": "item_id", "in": "path", "required": True, "schema": {"type": "integer"}}
|
|
165
|
+
],
|
|
166
|
+
"responses": {
|
|
167
|
+
"200": {
|
|
168
|
+
"description": "Item detail",
|
|
169
|
+
"content": {"application/json": {"schema": {
|
|
170
|
+
"type": "object",
|
|
171
|
+
"properties": {"data": {"$ref": f"#/components/schemas/{schema.name}"}}
|
|
172
|
+
}}}
|
|
173
|
+
},
|
|
174
|
+
"404": {"description": "Not found"}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if "update" in schema.permissions:
|
|
179
|
+
detail_ops["put"] = {
|
|
180
|
+
"tags": [tag],
|
|
181
|
+
"summary": f"Update a {schema.name.lower()}",
|
|
182
|
+
"parameters": [
|
|
183
|
+
{"name": "item_id", "in": "path", "required": True, "schema": {"type": "integer"}}
|
|
184
|
+
],
|
|
185
|
+
"requestBody": {
|
|
186
|
+
"required": True,
|
|
187
|
+
"content": {"application/json": {"schema": {"$ref": f"#/components/schemas/{schema.name}Create"}}}
|
|
188
|
+
},
|
|
189
|
+
"responses": {
|
|
190
|
+
"200": {
|
|
191
|
+
"description": "Updated",
|
|
192
|
+
"content": {"application/json": {"schema": {
|
|
193
|
+
"type": "object",
|
|
194
|
+
"properties": {"data": {"$ref": f"#/components/schemas/{schema.name}"}}
|
|
195
|
+
}}}
|
|
196
|
+
},
|
|
197
|
+
"404": {"description": "Not found"}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if "delete" in schema.permissions:
|
|
202
|
+
detail_ops["delete"] = {
|
|
203
|
+
"tags": [tag],
|
|
204
|
+
"summary": f"Delete a {schema.name.lower()}",
|
|
205
|
+
"parameters": [
|
|
206
|
+
{"name": "item_id", "in": "path", "required": True, "schema": {"type": "integer"}}
|
|
207
|
+
],
|
|
208
|
+
"responses": {
|
|
209
|
+
"204": {"description": "Deleted"},
|
|
210
|
+
"404": {"description": "Not found"}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if collection_ops:
|
|
215
|
+
paths[f"/{table}{suffix}"] = collection_ops
|
|
216
|
+
if detail_ops:
|
|
217
|
+
paths[f"/{table}/{{item_id}}{suffix}"] = detail_ops
|
|
218
|
+
|
|
219
|
+
return paths
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def get_swagger_html(title: str = "FlashAPI", openapi_url: str = "/openapi.json") -> str:
|
|
223
|
+
return SWAGGER_UI_HTML.format(title=title, openapi_url=openapi_url)
|
flashapi/fastapi.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from flashapi.features.pagination import paginate
|
|
2
|
+
from flashapi.features.filtering import apply_filters
|
|
3
|
+
from flashapi.features.sorting import apply_sorting
|
|
4
|
+
from flashapi.features.search import apply_search
|
|
5
|
+
|
|
6
|
+
__all__ = ["paginate", "apply_filters", "apply_sorting", "apply_search"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
RESERVED_PARAMS = {"page", "page_size", "sort", "search"}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def apply_filters(
|
|
9
|
+
items: list[dict[str, Any]],
|
|
10
|
+
filters: dict[str, str],
|
|
11
|
+
valid_fields: set[str],
|
|
12
|
+
) -> list[dict[str, Any]]:
|
|
13
|
+
"""Filter items by exact field match."""
|
|
14
|
+
active_filters = {
|
|
15
|
+
k: v for k, v in filters.items()
|
|
16
|
+
if k not in RESERVED_PARAMS and k in valid_fields
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if not active_filters:
|
|
20
|
+
return items
|
|
21
|
+
|
|
22
|
+
result = []
|
|
23
|
+
for item in items:
|
|
24
|
+
match = True
|
|
25
|
+
for field_name, value in active_filters.items():
|
|
26
|
+
item_value = item.get(field_name)
|
|
27
|
+
if str(item_value) != str(value):
|
|
28
|
+
match = False
|
|
29
|
+
break
|
|
30
|
+
if match:
|
|
31
|
+
result.append(item)
|
|
32
|
+
|
|
33
|
+
return result
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
DEFAULT_PAGE_SIZE = 20
|
|
6
|
+
MAX_PAGE_SIZE = 100
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def paginate(
|
|
10
|
+
items: list[dict[str, Any]],
|
|
11
|
+
page: int = 1,
|
|
12
|
+
page_size: int = DEFAULT_PAGE_SIZE,
|
|
13
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
14
|
+
"""Return a page slice and total count."""
|
|
15
|
+
page_size = min(max(1, page_size), MAX_PAGE_SIZE)
|
|
16
|
+
page = max(1, page)
|
|
17
|
+
total = len(items)
|
|
18
|
+
start = (page - 1) * page_size
|
|
19
|
+
end = start + page_size
|
|
20
|
+
return items[start:end], total
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def apply_search(
|
|
7
|
+
items: list[dict[str, Any]],
|
|
8
|
+
query: str | None,
|
|
9
|
+
searchable_fields: set[str],
|
|
10
|
+
) -> list[dict[str, Any]]:
|
|
11
|
+
"""Filter items where any searchable field contains the query string."""
|
|
12
|
+
if not query:
|
|
13
|
+
return items
|
|
14
|
+
|
|
15
|
+
query_lower = query.lower()
|
|
16
|
+
result = []
|
|
17
|
+
|
|
18
|
+
for item in items:
|
|
19
|
+
for field_name in searchable_fields:
|
|
20
|
+
value = item.get(field_name, "")
|
|
21
|
+
if value and query_lower in str(value).lower():
|
|
22
|
+
result.append(item)
|
|
23
|
+
break
|
|
24
|
+
|
|
25
|
+
return result
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def apply_sorting(
|
|
7
|
+
items: list[dict[str, Any]],
|
|
8
|
+
sort: str | None,
|
|
9
|
+
valid_fields: set[str],
|
|
10
|
+
) -> list[dict[str, Any]]:
|
|
11
|
+
"""Sort items by field. Prefix with - for descending."""
|
|
12
|
+
if not sort:
|
|
13
|
+
return items
|
|
14
|
+
|
|
15
|
+
descending = sort.startswith("-")
|
|
16
|
+
field_name = sort.lstrip("-")
|
|
17
|
+
|
|
18
|
+
if field_name not in valid_fields:
|
|
19
|
+
return items
|
|
20
|
+
|
|
21
|
+
return sorted(items, key=lambda x: x.get(field_name, ""), reverse=descending)
|
flashapi/flask.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from flashapi.core.schema import ModelSchema
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Inspector(ABC):
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def inspect(self, model_class: type, plural: str | None = None) -> ModelSchema:
|
|
11
|
+
...
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import fields as dc_fields, MISSING
|
|
4
|
+
|
|
5
|
+
from flashapi.core.schema import FieldSchema, FieldType, ModelSchema
|
|
6
|
+
from flashapi.core.pluralize import pluralize
|
|
7
|
+
from flashapi.inspectors.base import Inspector
|
|
8
|
+
|
|
9
|
+
TYPE_MAP: dict[type, FieldType] = {
|
|
10
|
+
str: FieldType.STRING,
|
|
11
|
+
int: FieldType.INTEGER,
|
|
12
|
+
float: FieldType.FLOAT,
|
|
13
|
+
bool: FieldType.BOOLEAN,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DataclassInspector(Inspector):
|
|
18
|
+
def inspect(self, model_class: type, plural: str | None = None) -> ModelSchema:
|
|
19
|
+
schema_fields: list[FieldSchema] = []
|
|
20
|
+
schema_fields.append(
|
|
21
|
+
FieldSchema(name="id", type=FieldType.INTEGER, required=False, primary_key=True, auto_generated=True)
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
for f in dc_fields(model_class):
|
|
25
|
+
field_type = TYPE_MAP.get(f.type, FieldType.STRING)
|
|
26
|
+
required = f.default is MISSING and f.default_factory is MISSING
|
|
27
|
+
default = None if required else f.default
|
|
28
|
+
|
|
29
|
+
schema_fields.append(FieldSchema(
|
|
30
|
+
name=f.name,
|
|
31
|
+
type=field_type,
|
|
32
|
+
required=required,
|
|
33
|
+
default=default,
|
|
34
|
+
))
|
|
35
|
+
|
|
36
|
+
model_name = model_class.__name__
|
|
37
|
+
plural_name = plural or pluralize(model_name)
|
|
38
|
+
|
|
39
|
+
return ModelSchema(name=model_name, plural=plural_name, fields=schema_fields)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import is_dataclass
|
|
4
|
+
|
|
5
|
+
from flashapi.core.schema import ModelSchema
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def inspect_model(model_class: type, plural: str | None = None) -> ModelSchema:
|
|
9
|
+
if _is_django_model(model_class):
|
|
10
|
+
from flashapi.inspectors.django import DjangoInspector
|
|
11
|
+
return DjangoInspector().inspect(model_class, plural)
|
|
12
|
+
|
|
13
|
+
if _is_sqlalchemy_model(model_class):
|
|
14
|
+
from flashapi.inspectors.sqlalchemy import SQLAlchemyInspector
|
|
15
|
+
return SQLAlchemyInspector().inspect(model_class, plural)
|
|
16
|
+
|
|
17
|
+
if _is_pydantic_model(model_class):
|
|
18
|
+
from flashapi.inspectors.pydantic import PydanticInspector
|
|
19
|
+
return PydanticInspector().inspect(model_class, plural)
|
|
20
|
+
|
|
21
|
+
if is_dataclass(model_class):
|
|
22
|
+
from flashapi.inspectors.dataclass import DataclassInspector
|
|
23
|
+
return DataclassInspector().inspect(model_class, plural)
|
|
24
|
+
|
|
25
|
+
raise TypeError(
|
|
26
|
+
f"Unsupported model type: {model_class}. "
|
|
27
|
+
"FlashAPI supports Django models, SQLAlchemy models, Pydantic models, and dataclasses."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_django_model(cls: type) -> bool:
|
|
32
|
+
try:
|
|
33
|
+
from django.db import models
|
|
34
|
+
return issubclass(cls, models.Model)
|
|
35
|
+
except ImportError:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_sqlalchemy_model(cls: type) -> bool:
|
|
40
|
+
return hasattr(cls, "__table__") and hasattr(cls, "__tablename__")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _is_pydantic_model(cls: type) -> bool:
|
|
44
|
+
try:
|
|
45
|
+
from pydantic import BaseModel
|
|
46
|
+
return issubclass(cls, BaseModel)
|
|
47
|
+
except ImportError:
|
|
48
|
+
return False
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from flashapi.core.schema import FieldSchema, FieldType, ModelSchema, RelationSchema
|
|
4
|
+
from flashapi.core.pluralize import pluralize
|
|
5
|
+
from flashapi.inspectors.base import Inspector
|
|
6
|
+
|
|
7
|
+
DJANGO_TYPE_MAP = {
|
|
8
|
+
"AutoField": FieldType.INTEGER,
|
|
9
|
+
"BigAutoField": FieldType.INTEGER,
|
|
10
|
+
"SmallAutoField": FieldType.INTEGER,
|
|
11
|
+
"CharField": FieldType.STRING,
|
|
12
|
+
"TextField": FieldType.TEXT,
|
|
13
|
+
"IntegerField": FieldType.INTEGER,
|
|
14
|
+
"BigIntegerField": FieldType.INTEGER,
|
|
15
|
+
"SmallIntegerField": FieldType.INTEGER,
|
|
16
|
+
"PositiveIntegerField": FieldType.INTEGER,
|
|
17
|
+
"FloatField": FieldType.FLOAT,
|
|
18
|
+
"DecimalField": FieldType.FLOAT,
|
|
19
|
+
"BooleanField": FieldType.BOOLEAN,
|
|
20
|
+
"DateField": FieldType.DATE,
|
|
21
|
+
"DateTimeField": FieldType.DATETIME,
|
|
22
|
+
"TimeField": FieldType.TIME,
|
|
23
|
+
"UUIDField": FieldType.UUID,
|
|
24
|
+
"JSONField": FieldType.JSON,
|
|
25
|
+
"BinaryField": FieldType.BINARY,
|
|
26
|
+
"EmailField": FieldType.STRING,
|
|
27
|
+
"URLField": FieldType.STRING,
|
|
28
|
+
"SlugField": FieldType.STRING,
|
|
29
|
+
"FileField": FieldType.STRING,
|
|
30
|
+
"ImageField": FieldType.STRING,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DjangoInspector(Inspector):
|
|
35
|
+
def inspect(self, model_class: type, plural: str | None = None) -> ModelSchema:
|
|
36
|
+
meta = model_class._meta
|
|
37
|
+
fields: list[FieldSchema] = []
|
|
38
|
+
|
|
39
|
+
for f in meta.get_fields():
|
|
40
|
+
if f.many_to_many or f.one_to_many:
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
field_type_name = type(f).__name__
|
|
44
|
+
field_type = DJANGO_TYPE_MAP.get(field_type_name, FieldType.STRING)
|
|
45
|
+
|
|
46
|
+
constraints = {}
|
|
47
|
+
if hasattr(f, "max_length") and f.max_length:
|
|
48
|
+
constraints["max_length"] = f.max_length
|
|
49
|
+
|
|
50
|
+
relation = None
|
|
51
|
+
is_fk = hasattr(f, "related_model") and f.related_model
|
|
52
|
+
if is_fk:
|
|
53
|
+
relation = RelationSchema(
|
|
54
|
+
type="one_to_one" if f.one_to_one else "many_to_one",
|
|
55
|
+
target=f.related_model.__name__,
|
|
56
|
+
)
|
|
57
|
+
field_type = FieldType.INTEGER
|
|
58
|
+
|
|
59
|
+
is_pk = getattr(f, "primary_key", False)
|
|
60
|
+
auto_generated = field_type_name in ("AutoField", "BigAutoField", "SmallAutoField")
|
|
61
|
+
has_default = hasattr(f, "default") and f.default is not None
|
|
62
|
+
if is_pk and has_default and not auto_generated:
|
|
63
|
+
auto_generated = True
|
|
64
|
+
required = not getattr(f, "blank", False) and not getattr(f, "null", False) and not has_default
|
|
65
|
+
default = f.default if has_default else None
|
|
66
|
+
|
|
67
|
+
field_name = getattr(f, "attname", f.name) if is_fk else f.name
|
|
68
|
+
|
|
69
|
+
fields.append(FieldSchema(
|
|
70
|
+
name=field_name,
|
|
71
|
+
type=field_type,
|
|
72
|
+
required=required and not is_pk,
|
|
73
|
+
default=default,
|
|
74
|
+
constraints=constraints,
|
|
75
|
+
primary_key=is_pk,
|
|
76
|
+
auto_generated=auto_generated,
|
|
77
|
+
relation=relation,
|
|
78
|
+
))
|
|
79
|
+
|
|
80
|
+
model_name = model_class.__name__
|
|
81
|
+
plural_name = plural or pluralize(model_name)
|
|
82
|
+
|
|
83
|
+
return ModelSchema(name=model_name, plural=plural_name, fields=fields)
|