Fast-Controller 0.0.0.dev2__tar.gz → 0.2.0b0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: Fast-Controller
3
- Version: 0.0.0.dev2
3
+ Version: 0.2.0b0
4
4
  Summary: The fastest way to a turn your models into a full ReST API
5
5
  Keywords: controller,base,rest,api,backend
6
6
  Author-Email: Cody M Sommer <bassmastacod@gmail.com>
@@ -0,0 +1,272 @@
1
+ from contextlib import contextmanager
2
+ from enum import Enum, auto
3
+ from typing import Optional, Callable
4
+
5
+ from daomodel import DAOModel
6
+ from daomodel.dao import NotFound
7
+ from daomodel.db import DAOFactory
8
+ from daomodel.transaction import Conflict
9
+ from fastapi import FastAPI, APIRouter, Request, Response, Depends, Path, Body, Query, Header
10
+ from fastapi.responses import JSONResponse
11
+ from sqlalchemy import Engine
12
+ from sqlalchemy.orm import sessionmaker
13
+ from sqlmodel import SQLModel
14
+
15
+ from fast_controller.resource import Resource
16
+ from fast_controller.util import docstring_format, InvalidInput
17
+
18
+
19
+ class Action(Enum):
20
+ VIEW = auto()
21
+ SEARCH = auto()
22
+ CREATE = auto()
23
+ UPSERT = auto()
24
+ MODIFY = auto()
25
+ RENAME = auto()
26
+ DELETE = auto()
27
+
28
+
29
+ class Controller:
30
+ def __init__(self,
31
+ prefix: Optional[str] = '',
32
+ app: Optional[FastAPI] = None,
33
+ engine: Optional[Engine] = None) -> None:
34
+ self.prefix = prefix
35
+ self.app = None
36
+ self.engine = None
37
+ self.models = None
38
+ if app is not None and engine is not None:
39
+ self.init_app(app, engine)
40
+ self.daos = Depends(self.dao_generator)
41
+
42
+ def init_app(self, app: FastAPI, engine: Engine) -> None:
43
+ self.app = app
44
+ self.engine = engine
45
+
46
+ @app.exception_handler(InvalidInput)
47
+ async def not_found_handler(request: Request, exc: InvalidInput):
48
+ return JSONResponse(status_code=400, content={"detail": exc.detail})
49
+
50
+ @app.exception_handler(NotFound)
51
+ async def not_found_handler(request: Request, exc: NotFound):
52
+ return JSONResponse(status_code=404, content={"detail": exc.detail})
53
+
54
+ @app.exception_handler(Conflict)
55
+ async def not_found_handler(request: Request, exc: Conflict):
56
+ return JSONResponse(status_code=409, content={"detail": exc.detail})
57
+
58
+ def dao_generator(self) -> DAOFactory:
59
+ """Yields a DAOFactory."""
60
+ with DAOFactory(sessionmaker(bind=self.engine)) as daos:
61
+ yield daos
62
+
63
+ @contextmanager
64
+ def dao_context(self):
65
+ yield from self.dao_generator()
66
+
67
+ def dependencies_for(self, resource: type[Resource], action: Action) -> list[Depends]:
68
+ return []
69
+
70
+ def register_resource(self,
71
+ resource: type[Resource],
72
+ skip: Optional[set[Action]] = None,
73
+ additional_endpoints: Optional[Callable] = None) -> None:
74
+ api_router = APIRouter(
75
+ prefix=self.prefix + resource.get_resource_path(),
76
+ tags=[resource.doc_name()])
77
+ self._register_resource_endpoints(api_router, resource, skip)
78
+ if additional_endpoints:
79
+ additional_endpoints(api_router, self)
80
+ self.app.include_router(api_router)
81
+
82
+ def _register_resource_endpoints(self,
83
+ router: APIRouter,
84
+ resource: type[Resource],
85
+ skip: Optional[set[Action]] = None) -> None:
86
+ if skip is None:
87
+ skip = set()
88
+ if Action.SEARCH not in skip:
89
+ self._register_search_endpoint(router, resource)
90
+ if Action.CREATE not in skip:
91
+ self._register_create_endpoint(router, resource)
92
+ if Action.UPSERT not in skip:
93
+ self._register_update_endpoint(router, resource)
94
+
95
+ pk = [p.name for p in resource.get_pk()]
96
+ path = "/".join([""] + ["{" + p + "}" for p in pk])
97
+
98
+ # Caveat: Only up to 2 columns are supported within a primary key.
99
+ # This allows us to avoid resorting to exec() while **kwargs is unsupported for Path variables
100
+ if len(pk) == 1:
101
+ if Action.VIEW not in skip:
102
+ self._register_view_endpoint(router, resource, path, pk)
103
+
104
+ # Caveat: Rename action is only supported for resources with a single column primary key
105
+ if Action.RENAME not in skip:
106
+ self._register_rename_endpoint(router, resource, path, pk)
107
+
108
+ # Caveat: Modify action is only supported for resources with a single column primary key
109
+ # Use Upsert instead for multi-column PK resources
110
+ if Action.MODIFY not in skip:
111
+ self._register_modify_endpoint(router, resource, path, pk)
112
+
113
+ # Caveat: Delete action is only supported for resources with a single column primary key
114
+ if Action.DELETE not in skip:
115
+ self._register_delete_endpoint(router, resource, path, pk)
116
+ elif len(pk) == 2:
117
+ if Action.VIEW not in skip:
118
+ self._register_view_endpoint_dual_pk(router, resource, path, pk)
119
+
120
+ def _register_search_endpoint(self, router: APIRouter, resource: type[Resource]):
121
+ @router.get(
122
+ "/",
123
+ response_model=list[resource.get_output_schema()],
124
+ dependencies=self.dependencies_for(resource, Action.SEARCH))
125
+ @docstring_format(resource=resource.doc_name())
126
+ def search(response: Response,
127
+ filters: resource.get_search_schema() = Query(),
128
+ x_page: Optional[int] = Header(default=None, gt=0),
129
+ x_per_page: Optional[int] = Header(default=None, gt=0),
130
+ daos: DAOFactory = self.daos) -> list[DAOModel]:
131
+ """Searches for {resource} by criteria"""
132
+ results = daos[resource].find(x_page, x_per_page, **filters.model_dump(exclude_unset=True))
133
+ response.headers["x-total-count"] = str(results.total)
134
+ response.headers["x-page"] = str(results.page)
135
+ response.headers["x-per-page"] = str(results.per_page)
136
+ return results
137
+
138
+ def _register_create_endpoint(self, router: APIRouter, resource: type[Resource]):
139
+ @router.post(
140
+ "/",
141
+ response_model=resource.get_detailed_output_schema(),
142
+ status_code=201,
143
+ dependencies=self.dependencies_for(resource, Action.CREATE))
144
+ @docstring_format(resource=resource.doc_name())
145
+ def create(model: resource.get_input_schema(),
146
+ daos: DAOFactory = self.daos) -> DAOModel:
147
+ """Creates a new {resource}"""
148
+ return daos[resource].create_with(**model.model_dump(exclude_unset=True))
149
+
150
+ def _register_update_endpoint(self, router: APIRouter, resource: type[Resource]):
151
+ @router.put(
152
+ "/",
153
+ response_model=resource.get_detailed_output_schema(),
154
+ dependencies=self.dependencies_for(resource, Action.UPSERT))
155
+ @docstring_format(resource=resource.doc_name())
156
+ def upsert(model: resource.get_input_schema(),
157
+ daos: DAOFactory = self.daos) -> SQLModel:
158
+ """Creates/modifies a {resource}"""
159
+ daos[resource].upsert(model)
160
+ return model
161
+
162
+ def _register_view_endpoint(self,
163
+ router: APIRouter,
164
+ resource: type[Resource],
165
+ path: str,
166
+ pk: list[str]):
167
+ @router.get(
168
+ path,
169
+ response_model=resource.get_detailed_output_schema(),
170
+ dependencies=self.dependencies_for(resource, Action.VIEW))
171
+ @docstring_format(resource=resource.doc_name())
172
+ def view(pk0=Path(alias=pk[0]),
173
+ daos: DAOFactory = self.daos) -> DAOModel:
174
+ """Retrieves a detailed view of a {resource}"""
175
+ return daos[resource].get(pk0)
176
+
177
+ def _register_view_endpoint_dual_pk(self,
178
+ router: APIRouter,
179
+ resource: type[Resource],
180
+ path: str,
181
+ pk: list[str]):
182
+ @router.get(
183
+ path,
184
+ response_model=resource.get_detailed_output_schema(),
185
+ dependencies=self.dependencies_for(resource, Action.VIEW))
186
+ @docstring_format(resource=resource.doc_name())
187
+ def view(pk0=Path(alias=pk[0]),
188
+ pk1=Path(alias=pk[1]),
189
+ daos: DAOFactory = self.daos) -> DAOModel:
190
+ """Retrieves a detailed view of a {resource}"""
191
+ return daos[resource].get(pk0, pk1)
192
+
193
+ def _register_rename_endpoint(self,
194
+ router: APIRouter,
195
+ resource: type[Resource],
196
+ path: str,
197
+ pk: list[str]):
198
+ @router.post(
199
+ f'{path}/rename',
200
+ response_model=resource.get_detailed_output_schema(),
201
+ dependencies=self.dependencies_for(resource, Action.RENAME))
202
+ @docstring_format(resource=resource.doc_name())
203
+ def rename(pk0=Path(alias=pk[0]),
204
+ new_id=Body(alias=pk[0]),
205
+ daos: DAOFactory = self.daos) -> DAOModel:
206
+ """Renames a {resource}"""
207
+ dao = daos[resource]
208
+ current = dao.get(pk0)
209
+ dao.rename(current, dao.get(new_id))
210
+ return current
211
+
212
+ def _register_merge_endpoint(self,
213
+ router: APIRouter,
214
+ resource: type[Resource],
215
+ path: str,
216
+ pk: list[str]):
217
+ @router.post(
218
+ f'{path}/merge',
219
+ response_model=resource.get_detailed_output_schema(),
220
+ dependencies=self.dependencies_for(resource, Action.RENAME))
221
+ @docstring_format(resource=resource.doc_name())
222
+ def merge(pk0=Path(alias=pk[0]),
223
+ target_id=Body(alias=pk[0]),
224
+ daos: DAOFactory = self.daos) -> DAOModel:
225
+ source = daos[resource].get(pk0)
226
+ # for model in all_models(self.engine):
227
+ # for column in model.get_references_of(resource):
228
+ #daos[type[model]].find(column.name=)
229
+ # if fk.column.table.name == target_table_name and fk.column.name in target_column_values:
230
+ # print(f"Foreign key in table {table.name} references the column '{fk.column.name}' in {target_table.name}")
231
+ # # Retrieve rows in this table that reference the target row
232
+ # conn = engine.connect()
233
+ # condition = (table.c[fk.parent.name] == target_column_values[fk.column.name])
234
+ # result = conn.execute(table.select().where(condition))
235
+ # referencing_rows.extend(result.fetchall())
236
+ # conn.close()
237
+ #
238
+ # return referencing_rows
239
+
240
+ def _register_modify_endpoint(self,
241
+ router: APIRouter,
242
+ resource: type[Resource],
243
+ path: str,
244
+ pk: list[str]):
245
+ @router.put(
246
+ path,
247
+ response_model=resource.get_detailed_output_schema(),
248
+ dependencies=self.dependencies_for(resource, Action.MODIFY))
249
+ @docstring_format(resource=resource.doc_name())
250
+ def update(model: resource.get_update_schema(), # TODO - Remove PK from input schema
251
+ pk0=Path(alias=pk[0]),
252
+ daos: DAOFactory = self.daos) -> DAOModel:
253
+ """Creates/modifies a {resource}"""
254
+ result = daos[resource].get(pk0)
255
+ result.set_values(**model.model_dump(exclude_unset=True))
256
+ daos[resource].commit(result)
257
+ return result
258
+
259
+ def _register_delete_endpoint(self,
260
+ router: APIRouter,
261
+ resource: type[Resource],
262
+ path: str,
263
+ pk: list[str]):
264
+ @router.delete(
265
+ path,
266
+ status_code=204,
267
+ dependencies=self.dependencies_for(resource, Action.DELETE))
268
+ @docstring_format(resource=resource.doc_name())
269
+ def delete(pk0=Path(alias=pk[0]),
270
+ daos: DAOFactory = self.daos) -> None:
271
+ """Deletes a {resource}"""
272
+ daos[resource].remove(pk0)
@@ -0,0 +1,107 @@
1
+ from inspect import isclass
2
+ from typing import Any
3
+
4
+ from daomodel import DAOModel
5
+ from pydantic import create_model
6
+ from sqlmodel import SQLModel
7
+
8
+
9
+ def either(preferred: Any, default: type[SQLModel]) -> type[SQLModel]:
10
+ """
11
+ Returns the preferred type if present, otherwise the default type.
12
+
13
+ :param preferred: The type to return if not None
14
+ :param default: The type to return if the preferred is not a model
15
+ :return: either the preferred type or the default type
16
+ """
17
+ return preferred if isclass(preferred) and issubclass(preferred, SQLModel) else default
18
+
19
+
20
+ class Resource(DAOModel):
21
+ __abstract__ = True
22
+ _default_schema: type[SQLModel]
23
+ _input_schema: type[SQLModel]
24
+ _update_schema: type[SQLModel]
25
+ _output_schema: type[SQLModel]
26
+ _detailed_output_schema: type[SQLModel]
27
+
28
+ @classmethod
29
+ def get_resource_path(cls) -> str:
30
+ """
31
+ Returns the URI path to this resource as defined by the 'path' class variable.
32
+ A default value of `/api/{resource_name} is returned unless overridden.
33
+
34
+ :return: The URI path to be used for this Resource
35
+ """
36
+ return "/api/" + cls.normalized_name()
37
+
38
+ @classmethod
39
+ def validate(cls, column_name, value):
40
+ return True
41
+
42
+ @classmethod
43
+ def get_search_schema(cls) -> type[SQLModel]:
44
+ """Returns an SQLModel representing the searchable fields"""
45
+ def get_field_name(field) -> str:
46
+ """Constructs the field's name, optionally prepending the table name."""
47
+ field_name = field.name
48
+ if hasattr(field, 'class_') and field.class_ is not cls and hasattr(field, 'table') and field.table.name:
49
+ field_name = f'{field.table.name}_{field_name}'
50
+ return field_name
51
+ def get_field_type(field) -> type:
52
+ field_type = field.type
53
+ if hasattr(field_type, 'impl'):
54
+ field_type = field_type.impl
55
+ return field_type.python_type
56
+ fields = [field[-1] if isinstance(field, tuple) else field for field in cls.get_searchable_properties()]
57
+ field_types = {
58
+ get_field_name(field): (get_field_type(field), None) for field in fields
59
+ }
60
+ return create_model(
61
+ f'{cls.doc_name()}SearchSchema',
62
+ **field_types
63
+ )
64
+
65
+ @classmethod
66
+ def get_base(cls) -> type[SQLModel]:
67
+ return cls
68
+
69
+ @classmethod
70
+ def set_default_schema(cls, schema: type[SQLModel]) -> None:
71
+ cls._default_schema = schema
72
+
73
+ @classmethod
74
+ def get_default_schema(cls) -> type[SQLModel]:
75
+ return either(cls._default_schema, cls)
76
+
77
+ @classmethod
78
+ def set_input_schema(cls, schema: type[SQLModel]) -> None:
79
+ cls._input_schema = schema
80
+
81
+ @classmethod
82
+ def get_input_schema(cls) -> type[SQLModel]:
83
+ return either(cls._input_schema, cls.get_default_schema())
84
+
85
+ @classmethod
86
+ def set_update_schema(cls, schema: type[SQLModel]) -> None:
87
+ cls._update_schema = schema
88
+
89
+ @classmethod
90
+ def get_update_schema(cls) -> type[SQLModel]:
91
+ return either(cls._update_schema, cls.get_input_schema())
92
+
93
+ @classmethod
94
+ def set_output_schema(cls, schema: type[SQLModel]) -> None:
95
+ cls._output_schema = schema
96
+
97
+ @classmethod
98
+ def get_output_schema(cls) -> type[SQLModel]:
99
+ return either(cls._output_schema, cls.get_default_schema())
100
+
101
+ @classmethod
102
+ def set_detailed_output_schema(cls, schema: type[SQLModel]) -> None:
103
+ cls._detailed_output_schema = schema
104
+
105
+ @classmethod
106
+ def get_detailed_output_schema(cls) -> type[SQLModel]:
107
+ return either(cls._detailed_output_schema, cls.get_output_schema())
@@ -0,0 +1,36 @@
1
+ from typing import Callable, get_type_hints, Optional
2
+
3
+ from sqlmodel import SQLModel
4
+
5
+
6
+ class InvalidInput(Exception):
7
+ """Indicates that the user provided bad input."""
8
+ def __init__(self, detail: str):
9
+ self.detail = detail
10
+
11
+
12
+ def docstring_format(**kwargs):
13
+ """
14
+ A decorator that formats the docstring of a function with specified values.
15
+
16
+ :param kwargs: The values to inject into the docstring
17
+ """
18
+ def decorator(func: Callable):
19
+ func.__doc__ = func.__doc__.format(**kwargs)
20
+ return func
21
+ return decorator
22
+
23
+
24
+ # TODO: Determine bast way to add test coverage
25
+ def all_optional(superclass: type[SQLModel]):
26
+ """Creates a new SQLModel for the specified class but having no required fields.
27
+
28
+ :param superclass: The SQLModel of which to make all fields Optional
29
+ :return: The newly wrapped Model
30
+ """
31
+ class OptionalModel(superclass):
32
+ pass
33
+ for field, field_type in get_type_hints(OptionalModel).items():
34
+ if not isinstance(field_type, type(Optional)):
35
+ OptionalModel.__annotations__[field] = Optional[field_type]
36
+ return OptionalModel
@@ -40,7 +40,7 @@ classifiers = [
40
40
  "Topic :: Software Development :: Libraries",
41
41
  "Typing :: Typed",
42
42
  ]
43
- version = "0.0.0.dev2"
43
+ version = "0.2.0b0"
44
44
 
45
45
  [project.license]
46
46
  text = "MIT"
@@ -0,0 +1,84 @@
1
+ from datetime import datetime
2
+ from typing import Any, Optional
3
+
4
+ import pytest
5
+ from daomodel import DAOModel
6
+ from daomodel.fields import Unsearchable, Identifier, ReferenceTo
7
+ from sqlmodel import SQLModel
8
+
9
+ from fast_controller import Resource, Controller
10
+ from fast_controller.resource import either
11
+
12
+
13
+ class Preferred(SQLModel):
14
+ pass
15
+
16
+
17
+ class Default(SQLModel):
18
+ pass
19
+
20
+
21
+ @pytest.mark.parametrize("preferred, default, expected", [
22
+ (Preferred, Default, Preferred),
23
+ (SQLModel, Default, SQLModel),
24
+ (DAOModel, Default, DAOModel),
25
+ (Resource, Default, Resource),
26
+ (None, Default, Default),
27
+ (1, Default, Default),
28
+ ("test", Default, Default),
29
+ (Controller, Default, Default)
30
+ ])
31
+ def test_either(preferred: Any, default: type[SQLModel], expected: type[SQLModel]):
32
+ assert either(preferred, default) == expected
33
+
34
+
35
+ def test_get_path():
36
+ class C(Resource):
37
+ pass
38
+ assert C.get_resource_path() == "/api/c"
39
+
40
+
41
+ class Author(Resource, table=True):
42
+ name: Identifier[str]
43
+ bio: Optional[str]
44
+ active: bool = True
45
+
46
+
47
+ class Book(Resource, table=True):
48
+ title: Identifier[str]
49
+ author_name: Author
50
+ description: Unsearchable[Optional[str]]
51
+ page_count: Optional[int]
52
+ publication_date: Optional[datetime]
53
+ publisher_name: str = ReferenceTo('publisher.name')
54
+
55
+
56
+ class Publisher(Resource, table=True):
57
+ name: Identifier[str]
58
+
59
+ class Meta:
60
+ searchable_relations = {
61
+ Book.title, Book.page_count, Book.publication_date,
62
+ (Book, Author.name),
63
+ (Book, Author.active)
64
+ }
65
+
66
+
67
+ def test_get_search_schema():
68
+ actual = Publisher.get_search_schema()
69
+ assert actual.__name__ == 'PublisherSearchSchema'
70
+ class Expected(SQLModel):
71
+ name: str
72
+ book_title: str
73
+ book_page_count: int
74
+ book_publication_date: datetime
75
+ author_name: str
76
+ author_active: bool
77
+ actual_fields = {k: v.annotation for k, v in actual.model_fields.items()}
78
+ expected_fields = {k: v.annotation for k, v in Expected.model_fields.items()}
79
+ assert actual_fields == expected_fields
80
+
81
+
82
+ # TODO - Not convinced on this design for schema definitions
83
+ def test_get_base_and_schemas():
84
+ pass
@@ -0,0 +1,27 @@
1
+ import inspect
2
+
3
+ from fast_controller import docstring_format
4
+
5
+
6
+ @docstring_format(key="value")
7
+ def test_docstring_format():
8
+ """{key}"""
9
+ assert inspect.getdoc(test_docstring_format) == "value"
10
+
11
+
12
+ @docstring_format(key="value")
13
+ def test_docstring_format__empty():
14
+ """"""
15
+ assert inspect.getdoc(test_docstring_format__empty) == ""
16
+
17
+
18
+ @docstring_format(key="value")
19
+ def test_docstring_format__multiple_values():
20
+ """{key}1, {key}2"""
21
+ assert inspect.getdoc(test_docstring_format__multiple_values) == "value1, value2"
22
+
23
+
24
+ @docstring_format(key1="value1", key2="value2")
25
+ def test_docstring_format__multiple_keys():
26
+ """{key1}, {key2}"""
27
+ assert inspect.getdoc(test_docstring_format__multiple_keys) == "value1, value2"
@@ -1,237 +0,0 @@
1
- from enum import Enum, auto
2
- from typing import Optional, Callable, Annotated
3
-
4
- from daomodel import DAOModel
5
- from daomodel.dao import NotFound
6
- from daomodel.db import create_engine, DAOFactory
7
- from daomodel.util import names_of
8
- from fastapi import FastAPI, APIRouter, Depends, Path, Body, Query
9
- from fastapi.openapi.models import Response
10
-
11
- from fast_controller.resource import Resource
12
- from fast_controller.util import docstring_format, paginated
13
-
14
-
15
- SessionLocal = create_engine()
16
- def get_daos() -> DAOFactory:
17
- """Yields a DAOFactory."""
18
- with DAOFactory(SessionLocal) as daos:
19
- yield daos
20
-
21
-
22
- class Action(Enum):
23
- VIEW = auto()
24
- SEARCH = auto()
25
- CREATE = auto()
26
- UPSERT = auto()
27
- MODIFY = auto()
28
- RENAME = auto()
29
- DELETE = auto()
30
-
31
-
32
- class Controller:
33
- def __init__(self, app: Optional[FastAPI] = None):
34
- self.app = app
35
-
36
- def init_app(self, app: FastAPI) -> None:
37
- self.app = app
38
-
39
- # todo : define both ways to define apis
40
- @classmethod
41
- def create_api_group(cls,
42
- resource: Optional[type[Resource]] = None,
43
- prefix: Optional[str] = None,
44
- skip: Optional[set[Action]] = None) -> APIRouter:
45
- api_router = APIRouter(prefix=prefix if prefix else resource.get_path())
46
- if resource:
47
- cls.__register_resource_endpoints(api_router, resource, skip)
48
- return api_router
49
-
50
- def register_resource(self,
51
- resource: type[Resource],
52
- skip: Optional[set[Action]] = None,
53
- additional_endpoints: Optional[Callable] = None) -> None:
54
- if skip is None:
55
- skip = set()
56
- api_router = APIRouter(prefix=resource.get_path(), tags=[resource.doc_name()])
57
- self.__register_resource_endpoints(api_router, resource, skip)
58
- if additional_endpoints:
59
- additional_endpoints(api_router)
60
- self.app.include_router(api_router)
61
-
62
- @classmethod
63
- def __register_resource_endpoints(cls, router: APIRouter, resource: type[Resource], skip) -> None:
64
- if Action.SEARCH not in skip:
65
- @router.get("/", response_model=list[resource.get_output_schema()])
66
- @docstring_format(resource=resource.doc_name())
67
- def search(response: Response,
68
- filters: Annotated[paginated(resource.get_search_schema()), Query()],
69
- daos: DAOFactory = Depends(get_daos)) -> list[DAOModel]:
70
- """Searches for {resource} by criteria"""
71
- results = daos[resource].find(**filters.model_dump())
72
- response.headers["x-total-count"] = str(results.total)
73
- response.headers["x-page"] = str(results.page)
74
- response.headers["x-per-page"] = str(results.per_page)
75
- return results
76
-
77
- if Action.CREATE not in skip:
78
- @router.post("/", response_model=resource.get_detailed_output_schema(), status_code=201)
79
- @docstring_format(resource=resource.doc_name())
80
- def create(data: Annotated[resource.get_input_schema(), Query()],
81
- daos: DAOFactory = Depends(get_daos)) -> DAOModel:
82
- """Creates a new {resource}"""
83
- return daos[resource].create_with(**data.model_dump())
84
-
85
- if Action.UPSERT not in skip:
86
- @router.put("/", response_model=resource.get_detailed_output_schema())
87
- @docstring_format(resource=resource.doc_name())
88
- def upsert(data: Annotated[resource.get_input_schema(), Query()],
89
- daos: DAOFactory = Depends(get_daos)) -> DAOModel:
90
- """Creates/modifies a {resource}"""
91
- return daos[resource].upsert(**data.model_dump())
92
-
93
- pk = [p.name for p in resource.get_pk()]
94
- path = "/".join([""] + ["{" + p + "}" for p in pk])
95
-
96
- # Caveat: Only up to 5 columns are supported within a primary key.
97
- # This allows us to avoid resorting to exec() while **kwargs is unsupported for Path variables
98
- match len(pk):
99
- case 1:
100
- if Action.VIEW not in skip:
101
- @router.get(path, response_model=resource.get_detailed_output_schema())
102
- @docstring_format(resource=resource.doc_name())
103
- def view(pk0 = Path(alias=pk[0]),
104
- daos: DAOFactory = Depends(get_daos)) -> DAOModel:
105
- """Retrieves a detailed view of a {resource}"""
106
- return daos[resource].get(pk0)
107
-
108
- # Caveat: Rename action is only supported for resources with a single column primary key
109
- if Action.RENAME not in skip:
110
- @router.post(path, response_model=resource.get_detailed_output_schema())
111
- @docstring_format(resource=resource.doc_name())
112
- def rename(pk0 = Path(alias=pk[0]),
113
- new_id = Body(alias=pk[0]),
114
- daos: DAOFactory = Depends(get_daos)) -> DAOModel:
115
- """Renames a {resource}"""
116
- current = daos[resource].get(pk0)
117
- try:
118
- existing = daos[resource].get(new_id)
119
- # todo Set FKs
120
- for fk in resource.get_fks():
121
- fk = new_id
122
- daos[resource].remove_model(current, commit=False)
123
- current = existing
124
- except NotFound:
125
- for p in names_of(current.get_pk()):
126
- setattr(current, p, new_id)
127
- # todo Set entire PK
128
- return daos[resource].update_model(current)
129
-
130
- # Caveat: Modify action is only supported for resources with a single column primary key
131
- # Use Upsert instead for multi-column PK resources
132
- if Action.MODIFY not in skip:
133
- @router.put(path, response_model=resource.get_detailed_output_schema())
134
- @docstring_format(resource=resource.doc_name())
135
- def update(data: Annotated[resource.get_update_schema(), Body()], # TODO - Remove PK from input schema
136
- pk0 = Path(alias=pk[0]),
137
- daos: DAOFactory = Depends(get_daos)) -> DAOModel:
138
- """Creates/modifies a {resource}"""
139
- result = daos[resource].get(pk0)
140
- result.copy_values(**data.model_dump())
141
- return daos[resource].update(**result.model_dump())
142
-
143
- if Action.DELETE not in skip:
144
- @router.delete(path, status_code=204)
145
- @docstring_format(resource=resource.doc_name())
146
- def delete(pk0 = Path(alias=pk[0]),
147
- daos: DAOFactory = Depends(get_daos)) -> None:
148
- """Deletes a {resource}"""
149
- daos[resource].remove(pk0)
150
-
151
- case 2:
152
- if Action.VIEW not in skip:
153
- @router.get(path, response_model=resource.get_detailed_output_schema())
154
- @docstring_format(resource=resource.doc_name())
155
- def view(pk0 = Path(alias=pk[0]),
156
- pk1 = Path(alias=pk[1]),
157
- daos: DAOFactory = Depends(get_daos)) -> DAOModel:
158
- """Retrieves a detailed view of a {resource}"""
159
- return daos[resource].get(pk0, pk1)
160
-
161
- if Action.DELETE not in skip:
162
- @router.delete(path, status_code=204)
163
- @docstring_format(resource=resource.doc_name())
164
- def delete(pk0 = Path(alias=pk[0]),
165
- pk1 = Path(alias=pk[1]),
166
- daos: DAOFactory = Depends(get_daos)) -> None:
167
- """Deletes a {resource}"""
168
- daos[resource].remove(pk0, pk1)
169
-
170
- case 3:
171
- if Action.VIEW not in skip:
172
- @router.get(path, response_model=resource.get_detailed_output_schema())
173
- @docstring_format(resource=resource.doc_name())
174
- def view(pk0 = Path(alias=pk[0]),
175
- pk1 = Path(alias=pk[1]),
176
- pk2 = Path(alias=pk[2]),
177
- daos: DAOFactory = Depends(get_daos)) -> DAOModel:
178
- """Retrieves a detailed view of a {resource}"""
179
- return daos[resource].get(pk0, pk1, pk2)
180
-
181
- if Action.DELETE not in skip:
182
- @router.delete(path, status_code=204)
183
- @docstring_format(resource=resource.doc_name())
184
- def delete(pk0 = Path(alias=pk[0]),
185
- pk1 = Path(alias=pk[1]),
186
- pk2 = Path(alias=pk[2]),
187
- daos: DAOFactory = Depends(get_daos)) -> None:
188
- """Deletes a {resource}"""
189
- daos[resource].remove(pk0, pk1, pk2)
190
-
191
- case 4:
192
- if Action.VIEW not in skip:
193
- @router.get(path, response_model=resource.get_detailed_output_schema())
194
- @docstring_format(resource=resource.doc_name())
195
- def view(pk0 = Path(alias=pk[0]),
196
- pk1 = Path(alias=pk[1]),
197
- pk2 = Path(alias=pk[2]),
198
- pk3 = Path(alias=pk[3]),
199
- daos: DAOFactory = Depends(get_daos)) -> DAOModel:
200
- """Retrieves a detailed view of a {resource}"""
201
- return daos[resource].get(pk0, pk1, pk2, pk3)
202
-
203
- if Action.DELETE not in skip:
204
- @router.delete(path, status_code=204)
205
- @docstring_format(resource=resource.doc_name())
206
- def delete(pk0 = Path(alias=pk[0]),
207
- pk1 = Path(alias=pk[1]),
208
- pk2 = Path(alias=pk[2]),
209
- pk3 = Path(alias=pk[3]),
210
- daos: DAOFactory = Depends(get_daos)) -> None:
211
- """Deletes a {resource}"""
212
- daos[resource].remove(pk0, pk1, pk2, pk3)
213
-
214
- case 5:
215
- if Action.VIEW not in skip:
216
- @router.get(path, response_model=resource.get_detailed_output_schema())
217
- @docstring_format(resource=resource.doc_name())
218
- def view(pk0 = Path(alias=pk[0]),
219
- pk1 = Path(alias=pk[1]),
220
- pk2 = Path(alias=pk[2]),
221
- pk3 = Path(alias=pk[3]),
222
- pk4 = Path(alias=pk[4]),
223
- daos: DAOFactory = Depends(get_daos)) -> DAOModel:
224
- """Retrieves a detailed view of a {resource}"""
225
- return daos[resource].get(pk0, pk1, pk2, pk3, pk4)
226
-
227
- if Action.DELETE not in skip:
228
- @router.delete(path, status_code=204)
229
- @docstring_format(resource=resource.doc_name())
230
- def delete(pk0 = Path(alias=pk[0]),
231
- pk1 = Path(alias=pk[1]),
232
- pk2 = Path(alias=pk[2]),
233
- pk3 = Path(alias=pk[3]),
234
- pk4 = Path(alias=pk[4]),
235
- daos: DAOFactory = Depends(get_daos)) -> None:
236
- """Deletes a {resource}"""
237
- daos[resource].remove(pk0, pk1, pk2, pk3, pk4)
@@ -1,80 +0,0 @@
1
- from inspect import isclass
2
- from typing import ClassVar
3
-
4
- from daomodel import DAOModel
5
- from sqlmodel import SQLModel
6
-
7
-
8
- def either(preferred, default):
9
- return preferred if isclass(preferred) and issubclass(preferred, SQLModel) else default
10
-
11
-
12
- class Resource(DAOModel):
13
- __abstract__ = True
14
- _default_schema: type[SQLModel]
15
- _search_schema: type[SQLModel]
16
- _input_schema: type[SQLModel]
17
- _update_schema: type[SQLModel]
18
- _output_schema: type[SQLModel]
19
- _detailed_output_schema: type[SQLModel]
20
- path: ClassVar[str]
21
-
22
- @classmethod
23
- def get_path(cls) -> str:
24
- return getattr(cls, "path", "/api/" + cls.normalized_name())
25
-
26
- @classmethod
27
- def validate(cls, column_name, value):
28
- return True
29
-
30
- @classmethod
31
- def get_base(cls) -> type[SQLModel]:
32
- return cls
33
-
34
- @classmethod
35
- def set_default_schema(cls, schema: type[SQLModel]) -> None:
36
- cls._default_schema = schema
37
-
38
- @classmethod
39
- def get_default_schema(cls) -> type[SQLModel]:
40
- return cls._default_schema
41
-
42
- @classmethod
43
- def set_search_schema(cls, schema: type[SQLModel]) -> None:
44
- cls._search_schema = schema
45
-
46
- @classmethod
47
- def get_search_schema(cls) -> type[SQLModel]:
48
- return either(cls._search_schema, cls.get_default_schema())
49
-
50
- @classmethod
51
- def set_input_schema(cls, schema: type[SQLModel]) -> None:
52
- cls._input_schema = schema
53
-
54
- @classmethod
55
- def get_input_schema(cls) -> type[SQLModel]:
56
- return either(cls._input_schema, cls.get_default_schema())
57
-
58
- @classmethod
59
- def set_update_schema(cls, schema: type[SQLModel]) -> None:
60
- cls._update_schema = schema
61
-
62
- @classmethod
63
- def get_update_schema(cls) -> type[SQLModel]:
64
- return either(cls._update_schema, cls.get_default_schema())
65
-
66
- @classmethod
67
- def set_output_schema(cls, schema: type[SQLModel]) -> None:
68
- cls._output_schema = schema
69
-
70
- @classmethod
71
- def get_output_schema(cls) -> type[SQLModel]:
72
- return either(cls._output_schema, cls.get_default_schema())
73
-
74
- @classmethod
75
- def set_detailed_output_schema(cls, schema: type[SQLModel]) -> None:
76
- cls._detailed_output_schema = schema
77
-
78
- @classmethod
79
- def get_detailed_output_schema(cls) -> type[SQLModel]:
80
- return either(cls._detailed_output_schema, cls.get_output_schema())
@@ -1,19 +0,0 @@
1
- from typing import Callable, Optional
2
-
3
- from sqlmodel import Field, SQLModel
4
-
5
-
6
- def docstring_format(**kwargs):
7
- def decorator(func: Callable):
8
- func.__doc__ = func.__doc__.format(**kwargs)
9
- return func
10
- return decorator
11
-
12
-
13
- def paginated(superclass: type[SQLModel]):
14
- class OptionalPK(superclass, table=True): # table=True is a hack to make PK optional
15
- pass
16
- class Paginated(OptionalPK):
17
- page: Optional[int] = Field(default=None, gt=0)
18
- per_page: Optional[int] = Field(default=None, gt=0)
19
- return Paginated
File without changes
File without changes