auto-rest-api 0.1.2__tar.gz → 0.1.3__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.

Potentially problematic release.


This version of auto-rest-api might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: auto-rest-api
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Automatically map database schemas and deploy per-table REST API endpoints.
5
5
  License: GPL-3.0-only
6
6
  Keywords: Better,HPC,automatic,rest,api
@@ -51,16 +51,15 @@ This makes it easy to incorporate handlers into a FastAPI application.
51
51
  """
52
52
 
53
53
  import logging
54
- from typing import Awaitable, Callable
54
+ from typing import Awaitable, Callable, Literal, Optional
55
55
 
56
- from fastapi import Depends, Response
56
+ from fastapi import Depends, Query, Response
57
57
  from pydantic import create_model
58
58
  from pydantic.main import BaseModel as PydanticModel
59
59
  from sqlalchemy import insert, MetaData, select, Table
60
60
 
61
61
  from .interfaces import *
62
62
  from .models import *
63
- from .params import *
64
63
  from .queries import *
65
64
 
66
65
  __all__ = [
@@ -192,21 +191,30 @@ def create_list_records_handler(engine: DBEngine, table: Table) -> Callable[...,
192
191
  """
193
192
 
194
193
  interface = create_interface(table)
194
+ interface_opt = create_interface(table, mode="optional")
195
+ columns = tuple(table.columns.keys())
195
196
 
196
197
  async def list_records_handler(
197
198
  response: Response,
198
199
  session: DBSession = Depends(create_session_iterator(engine)),
199
- pagination_params: dict[str, int] = create_pagination_dependency(table),
200
- ordering_params: dict[str, int] = create_ordering_dependency(table),
200
+ _limit_: int = Query(0, ge=0, description="The maximum number of records to return."),
201
+ _offset_: int = Query(0, ge=0, description="The starting index of the returned records."),
202
+ _order_by_: Optional[Literal[*columns]] = Query(None, description="The field name to sort by."),
203
+ _direction_: Literal["asc", "desc"] = Query("asc", description="Sort results in 'asc' or 'desc' order.")
201
204
  ) -> list[interface]:
202
205
  """Fetch a list of records from the database.
203
206
 
204
207
  URL query parameters are used to enable filtering, ordering, and paginating returned values.
205
208
  """
206
209
 
210
+ response.headers["X-Pagination-Limit"] = str(_limit_)
211
+ response.headers["X-Pagination-Offset"] = str(_offset_)
212
+ response.headers["X-Order-By"] = str(_order_by_)
213
+ response.headers["X-Order-Direction"] = str(_direction_)
214
+
207
215
  query = select(table)
208
- query = apply_pagination_params(query, pagination_params, response)
209
- query = apply_ordering_params(query, ordering_params, response)
216
+ query = apply_pagination_params(query, _limit_, _offset_)
217
+ query = apply_ordering_params(query, _order_by_, _direction_)
210
218
 
211
219
  result = await execute_session_query(session, query)
212
220
  return [row._mapping for row in result.all()]
@@ -0,0 +1,115 @@
1
+ """Pydantic models are used to facilitate data validation and to define
2
+ interfaces for FastAPI endpoint handlers. The `interfaces` module
3
+ provides utility functions for converting SQLAlchemy models into
4
+ Pydantic interfaces. Interfaces can be created using different modes
5
+ which force interface fields to be optional or read only.
6
+
7
+ !!! example "Example: Creating an Interface"
8
+
9
+ The `create_interface_default` method creates an interface class
10
+ based on a SQLAlchemy table.
11
+
12
+ ```python
13
+ default_interface = create_interface(database_model)
14
+ required_interface = create_interface(database_model, mode="required")
15
+ optional_interface = create_interface(database_model, mode="optional")
16
+ ```
17
+ """
18
+
19
+ from typing import Any, Iterator, Literal
20
+
21
+ from pydantic import BaseModel as PydanticModel, create_model
22
+ from sqlalchemy import Column, Table
23
+
24
+ __all__ = ["create_interface"]
25
+
26
+ MODE_TYPE = Literal["default", "required", "optional"]
27
+
28
+
29
+ def iter_columns(table: Table, pk_only: bool = False) -> Iterator[Column]:
30
+ """Iterate over the columns of a SQLAlchemy model.
31
+
32
+ Args:
33
+ table: The table to iterate columns over.
34
+ pk_only: If True, only iterate over primary key columns.
35
+
36
+ Yields:
37
+ A column of the SQLAlchemy model.
38
+ """
39
+
40
+ for column in table.columns.values():
41
+ if column.primary_key or not pk_only:
42
+ yield column
43
+
44
+
45
+ def create_field_definition(col: Column, mode: MODE_TYPE = "default") -> tuple[type[any], any]:
46
+ """Return a tuple with the type and default value for a database table column.
47
+
48
+ The returned tuple is compatible for use with Pydantic as a field definition
49
+ during dynamic model generation. The `mode` argument modifies returned
50
+ values to enforce different behavior in the generated Pydantic interface.
51
+
52
+ Modes:
53
+ default: Values are marked as (not)required based on the column schema.
54
+ required: Values are always marked required.
55
+ required: Values are always marked optional.
56
+
57
+ Args:
58
+ col: The column to return values for.
59
+ mode: The mode to use when determining the default value.
60
+
61
+ Returns:
62
+ The default value for the column.
63
+ """
64
+
65
+ try:
66
+ col_type = col.type.python_type
67
+
68
+ except NotImplementedError:
69
+ col_type = Any
70
+
71
+ col_default = getattr(col.default, "arg", col.default)
72
+
73
+ if mode == "required":
74
+ return col_type, ...
75
+
76
+ elif mode == "optional":
77
+ return col_type | None, col_default
78
+
79
+ elif mode == "default" and (col.nullable or col.default):
80
+ return col_type | None, col_default
81
+
82
+ elif mode == "default":
83
+ return col_type, ...
84
+
85
+ raise RuntimeError(f"Unknown mode: {mode}")
86
+
87
+
88
+ def create_interface(table: Table, pk_only: bool = False, mode: MODE_TYPE = "default") -> type[PydanticModel]:
89
+ """Create a Pydantic interface for a SQLAlchemy model where all fields are required.
90
+
91
+ Modes:
92
+ default: Values are marked as (not)required based on the column schema.
93
+ required: Values are always marked required.
94
+ required: Values are always marked optional.
95
+
96
+ Args:
97
+ table: The SQLAlchemy table to create an interface for.
98
+ pk_only: If True, only include primary key columns.
99
+ mode: Whether to force fields to all be optional or required.
100
+
101
+ Returns:
102
+ A dynamically generated Pydantic model with all fields required.
103
+ """
104
+
105
+ # Map field names to the column type and default value.
106
+ fields = {
107
+ col.name: create_field_definition(col, mode) for col in iter_columns(table, pk_only)
108
+ }
109
+
110
+ # Create a unique name for the interface
111
+ name = f"{table.name}-{mode.title()}"
112
+ if pk_only:
113
+ name += '-PK'
114
+
115
+ return create_model(name, __config__={'arbitrary_types_allowed': True}, **fields)
@@ -19,15 +19,18 @@ handling and provides a streamlined interface for database interactions.
19
19
  result = await execute_session_query(async_session, query)
20
20
  ```
21
21
  """
22
+ from typing import Literal
22
23
 
23
24
  from fastapi import HTTPException
24
- from sqlalchemy import Executable, Result
25
+ from sqlalchemy import asc, desc, Executable, Result, Select
25
26
  from sqlalchemy.ext.asyncio import AsyncSession
26
27
  from starlette import status
27
28
 
28
29
  from auto_rest.models import DBSession
29
30
 
30
31
  __all__ = [
32
+ "apply_ordering_params",
33
+ "apply_pagination_params",
31
34
  "commit_session",
32
35
  "delete_session_record",
33
36
  "execute_session_query",
@@ -35,6 +38,64 @@ __all__ = [
35
38
  ]
36
39
 
37
40
 
41
+ def apply_ordering_params(
42
+ query: Select,
43
+ order_by: str | None = None,
44
+ direction: Literal["desc", "asc"] = "asc"
45
+ ) -> Select:
46
+ """Apply ordering to a database query.
47
+
48
+ Returns a copy of the provided query with ordering parameters applied.
49
+
50
+ Args:
51
+ query: The database query to apply parameters to.
52
+ order_by: The name of the column to order by.
53
+ direction: The direction to order by (defaults to "asc").
54
+
55
+ Returns:
56
+ A copy of the query modified to return ordered values.
57
+ """
58
+
59
+ if order_by is None:
60
+ return query
61
+
62
+ if order_by not in query.columns:
63
+ raise ValueError(f"Invalid column name: {order_by}")
64
+
65
+ # Default to ascending order for an invalid ordering direction
66
+ if direction == "desc":
67
+ return query.order_by(desc(order_by))
68
+
69
+ elif direction == "asc":
70
+ return query.order_by(asc(order_by))
71
+
72
+ raise ValueError(f"Invalid direction, use 'asc' or 'desc': {direction}")
73
+
74
+
75
+ def apply_pagination_params(query: Select, limit: int = 0, offset: int = 0) -> Select:
76
+ """Apply pagination to a database query.
77
+
78
+ Returns a copy of the provided query with offset and limit parameters applied.
79
+
80
+ Args:
81
+ query: The database query to apply parameters to.
82
+ limit: The number of results to return.
83
+ offset: The offset to start with.
84
+
85
+ Returns:
86
+ A copy of the query modified to only return the paginated values.
87
+ """
88
+
89
+ if offset < 0 or limit < 0:
90
+ raise ValueError("Pagination parameters cannot be negative")
91
+
92
+ # Do not apply pagination if not requested
93
+ if limit == 0:
94
+ return query
95
+
96
+ return query.offset(offset or 0).limit(limit)
97
+
98
+
38
99
  async def commit_session(session: DBSession) -> None:
39
100
  """Commit a SQLAlchemy session.
40
101
 
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "auto-rest-api"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  readme = "README.md"
9
9
  description = "Automatically map database schemas and deploy per-table REST API endpoints."
10
10
  authors = [{ name = "Better HPC LLC" }]
@@ -45,7 +45,6 @@ packages = [
45
45
  { include = "auto_rest" },
46
46
  ]
47
47
 
48
-
49
48
  [tool.poetry.group.tests.dependencies]
50
49
  coverage = "*"
51
50
 
@@ -56,3 +55,8 @@ mkdocstrings-python = "^1.13.0"
56
55
 
57
56
  [tool.poetry.scripts]
58
57
  auto-rest = "auto_rest.__main__:main"
58
+
59
+ [tool.coverage.run]
60
+ branch = true
61
+ source = ["auto_rest"]
62
+ omit = ["tests/*"]
@@ -1,126 +0,0 @@
1
- """Pydantic models are used to facilitate data validation and to define
2
- interfaces for FastAPI endpoint handlers. The `interfaces` module
3
- provides utility functions for converting SQLAlchemy models into
4
- Pydantic interfaces. Interfaces can be created using different modes
5
- which force interface fields to be optional or read only.
6
-
7
- !!! example "Example: Creating an Interface"
8
-
9
- The `create_interface_default` method creates an interface class
10
- based on a SQLAlchemy table.
11
-
12
- ```python
13
- default_interface = create_interface_default(database_model)
14
- required_interface = create_interface_required(database_model, mode="required")
15
- optional_interface = create_interface_optional(database_model, mode="optional")
16
- ```
17
- """
18
- from typing import Iterator, Literal
19
-
20
- from pydantic import BaseModel as PydanticModel, create_model
21
- from sqlalchemy import Column, Table
22
-
23
- __all__ = ["create_interface"]
24
-
25
- from sqlalchemy.sql.schema import ScalarElementColumnDefault
26
-
27
- MODES = Literal["default", "required", "optional"]
28
-
29
-
30
- def iter_columns(table: Table, pk_only: bool = False) -> Iterator[Column]:
31
- """Iterate over the columns of a SQLAlchemy model.
32
-
33
- Args:
34
- table: The table to iterate columns over.
35
- pk_only: If True, only iterate over primary key columns.
36
-
37
- Yields:
38
- A column of the SQLAlchemy model.
39
- """
40
-
41
- for column in table.columns:
42
- if column.primary_key or not pk_only:
43
- yield column
44
-
45
-
46
- def get_column_type(col: Column) -> type[any]:
47
- """Return the Python type corresponding to a column's DB datatype.
48
-
49
- Returns the `any` type for DBMS drivers that do not support mapping DB
50
- types to Python primitives,
51
-
52
- Args:
53
- col: The column to determine a type for.
54
-
55
- Returns:
56
- The equivalent Python type for the column data.
57
- """
58
-
59
- try:
60
- return col.type.python_type
61
-
62
- # Catch any error, but list the expected ones explicitly
63
- except (NotImplementedError, Exception):
64
- return any
65
-
66
-
67
- def get_column_default(col: Column, mode: MODES) -> any:
68
- """Return the default value for a column.
69
-
70
- Args:
71
- col: The column to determine a default value for.
72
- mode: The mode to use when determining the default value.
73
-
74
- Returns:
75
- The default value for the column.
76
- """
77
-
78
- # Extract the default value from the SQLAlchemy wrapper class
79
- sqla_default = col.default
80
- default = getattr(sqla_default, "arg", None) or sqla_default
81
-
82
- if mode == "required":
83
- return ...
84
-
85
- elif mode == "optional":
86
- return default
87
-
88
- elif mode == "default":
89
- if col.nullable or (col.default is not None):
90
- return default
91
-
92
- return ...
93
-
94
- raise RuntimeError(f"Unknown mode: {mode}")
95
-
96
-
97
- def create_interface(
98
- table: Table,
99
- pk_only: bool = False,
100
- mode: MODES = "default"
101
- ) -> type[PydanticModel]:
102
- """Create a Pydantic interface for a SQLAlchemy model where all fields are required.
103
-
104
- Args:
105
- table: The SQLAlchemy table to create an interface for.
106
- pk_only: If True, only include primary key columns.
107
- mode: Whether to force fields to all be optional or required.
108
-
109
- Returns:
110
- A dynamically generated Pydantic model with all fields required.
111
- """
112
-
113
- # Map field names to the column type and default value.
114
- columns = iter_columns(table, pk_only)
115
- fields = {
116
- col.name: (get_column_type(col), get_column_default(col, mode))
117
- for col in columns
118
- }
119
-
120
- # Dynamically create a unique name for the interface
121
- name_parts = [table.name, mode.title()]
122
- if pk_only:
123
- name_parts.insert(1, 'PK')
124
-
125
- interface_name = '-'.join(name_parts)
126
- return create_model(interface_name, **fields)
@@ -1,174 +0,0 @@
1
- """
2
- The `params` module provides utilities for extracting and applying query
3
- parameters from incoming HTTP requests. These utilities ensure the consistent
4
- parsing, validation, and application of query parameters, and automatically
5
- update HTTP response headers to reflect applied query options.
6
-
7
- Parameter functions are designed in pairs of two. The first function is a
8
- factory for creating an injectable FastAPI dependency. The dependency
9
- is used to parse parameters from incoming requests and applies high level
10
- validation against the parsed values. The second function to applies the
11
- validated arguments onto a SQLAlchemy query and returns the updated query.
12
-
13
- !!! example "Example: Parameter Parsing and Application"
14
-
15
- ```python
16
- from fastapi import FastAPI, Response
17
- from sqlalchemy import select
18
- from auto_rest.query_params import create_pagination_dependency, apply_pagination_params
19
-
20
- app = FastAPI()
21
-
22
- @app.get("/items/")
23
- async def list_items(
24
- pagination_params: dict = create_pagination_dependency(model),
25
- response: Response
26
- ):
27
- query = select(model)
28
- query = apply_pagination_params(query, pagination_params, response)
29
- return ... # Logic to further process and execute the query goes here
30
- ```
31
- """
32
-
33
- from collections.abc import Callable
34
- from typing import Literal, Optional
35
-
36
- from fastapi import Depends, Query
37
- from sqlalchemy import asc, desc, Table
38
- from sqlalchemy.sql.selectable import Select
39
- from starlette.responses import Response
40
-
41
- __all__ = [
42
- "apply_ordering_params",
43
- "apply_pagination_params",
44
- "create_ordering_dependency",
45
- "create_pagination_dependency",
46
- ]
47
-
48
-
49
- def create_ordering_dependency(table: Table) -> Callable[..., dict]:
50
- """Create an injectable dependency for fetching ordering arguments from query parameters.
51
-
52
- Args:
53
- table: The database table to create the dependency for.
54
-
55
- Returns:
56
- An injectable FastAPI dependency.
57
- """
58
-
59
- columns = tuple(table.columns.keys())
60
-
61
- def get_ordering_params(
62
- _order_by_: Optional[Literal[*columns]] = Query(None, description="The field name to sort by."),
63
- _direction_: Optional[Literal["asc", "desc"]] = Query(None, description="Sort results in 'asc' or 'desc' order.")
64
- ) -> dict:
65
- """Extract ordering parameters from request query parameters.
66
-
67
- Args:
68
- _order_by_: The field to order by.
69
- _direction_: The direction to order by.
70
-
71
- Returns:
72
- dict: A dictionary containing the `order_by` and `direction` values.
73
- """
74
-
75
- return {"order_by": _order_by_, "direction": _direction_}
76
-
77
- return Depends(get_ordering_params)
78
-
79
-
80
- def apply_ordering_params(query: Select, params: dict, response: Response) -> Select:
81
- """Apply ordering to a database query.
82
-
83
- Returns a copy of the provided query with ordering parameters applied.
84
- This method is compatible with parameters returned by the `get_ordering_params` method.
85
- Ordering is not applied for invalid params, but response headers are still set.
86
-
87
- Args:
88
- query: The database query to apply parameters to.
89
- params: A dictionary containing parsed URL parameters.
90
- response: The outgoing HTTP response object.
91
-
92
- Returns:
93
- A copy of the query modified to return ordered values.
94
- """
95
-
96
- order_by = params.get("order_by")
97
- direction = params.get("direction")
98
-
99
- # Set common response headers
100
- response.headers["X-Order-By"] = str(order_by)
101
- response.headers["X-Order-Direction"] = str(direction)
102
-
103
- if order_by is None:
104
- response.headers["X-Order-Applied"] = "false"
105
- return query
106
-
107
- # Default to ascending order for an invalid ordering direction
108
- response.headers["X-Order-Applied"] = "true"
109
- if direction == "desc":
110
- return query.order_by(desc(order_by))
111
-
112
- else:
113
- return query.order_by(asc(order_by))
114
-
115
-
116
- def create_pagination_dependency(table: Table) -> Callable[..., dict]:
117
- """Create an injectable dependency for fetching pagination arguments from query parameters.
118
-
119
- Args:
120
- table: The database table to create the dependency for.
121
-
122
- Returns:
123
- An injectable FastAPI dependency.
124
- """
125
-
126
- def get_pagination_params(
127
- _limit_: Optional[int] = Query(None, ge=0, description="The maximum number of records to return."),
128
- _offset_: Optional[int] = Query(None, ge=0, description="The starting index of the returned records."),
129
- ) -> dict[str, int]:
130
- """Extract pagination parameters from request query parameters.
131
-
132
- Args:
133
- _limit_: The maximum number of records to return.
134
- _offset_: The starting index of the returned records.
135
-
136
- Returns:
137
- dict: A dictionary containing the `limit` and `offset` values.
138
- """
139
-
140
- return {"limit": _limit_, "offset": _offset_}
141
-
142
- return Depends(get_pagination_params)
143
-
144
-
145
- def apply_pagination_params(query: Select, params: dict[str, int], response: Response) -> Select:
146
- """Apply pagination to a database query.
147
-
148
- Returns a copy of the provided query with offset and limit parameters applied.
149
- This method is compatible with parameters returned by the `get_pagination_params` method.
150
- Pagination is not applied for invalid params, but response headers are still set.
151
-
152
- Args:
153
- query: The database query to apply parameters to.
154
- params: A dictionary containing parsed URL parameters.
155
- response: The outgoing HTTP response object.
156
-
157
- Returns:
158
- A copy of the query modified to only return the paginated values.
159
- """
160
-
161
- limit = params.get("limit")
162
- offset = params.get("offset")
163
-
164
- # Set common response headers
165
- response.headers["X-Pagination-Limit"] = str(limit)
166
- response.headers["X-Pagination-Offset"] = str(offset)
167
-
168
- # Do not apply pagination if not requested
169
- if limit in (0, None):
170
- response.headers["X-Pagination-Applied"] = "false"
171
- return query
172
-
173
- response.headers["X-Pagination-Applied"] = "true"
174
- return query.offset(offset or 0).limit(limit)
File without changes
File without changes