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,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
@@ -0,0 +1,5 @@
1
+ """FlashAPI Django adapter — public entry point."""
2
+
3
+ from flashapi.adapters.django import generate_urls
4
+
5
+ __all__ = ["generate_urls"]
File without changes
@@ -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,5 @@
1
+ """FlashAPI FastAPI adapter — public entry point."""
2
+
3
+ from flashapi.adapters.fastapi import FlashAPI
4
+
5
+ __all__ = ["FlashAPI"]
@@ -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,5 @@
1
+ """FlashAPI Flask adapter — public entry point."""
2
+
3
+ from flashapi.adapters.flask import register_models
4
+
5
+ __all__ = ["register_models"]
@@ -0,0 +1,3 @@
1
+ from flashapi.inspectors.detect import inspect_model
2
+
3
+ __all__ = ["inspect_model"]
@@ -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)