auto-rest-api 0.1.2__tar.gz → 0.1.4__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.
- {auto_rest_api-0.1.2 → auto_rest_api-0.1.4}/PKG-INFO +2 -6
- {auto_rest_api-0.1.2 → auto_rest_api-0.1.4}/README.md +1 -5
- auto_rest_api-0.1.4/auto_rest/__main__.py +70 -0
- {auto_rest_api-0.1.2 → auto_rest_api-0.1.4}/auto_rest/app.py +31 -8
- {auto_rest_api-0.1.2 → auto_rest_api-0.1.4}/auto_rest/cli.py +3 -5
- {auto_rest_api-0.1.2 → auto_rest_api-0.1.4}/auto_rest/handlers.py +16 -8
- auto_rest_api-0.1.4/auto_rest/interfaces.py +115 -0
- {auto_rest_api-0.1.2 → auto_rest_api-0.1.4}/auto_rest/models.py +35 -13
- {auto_rest_api-0.1.2 → auto_rest_api-0.1.4}/auto_rest/queries.py +63 -1
- {auto_rest_api-0.1.2 → auto_rest_api-0.1.4}/auto_rest/routers.py +19 -15
- {auto_rest_api-0.1.2 → auto_rest_api-0.1.4}/pyproject.toml +6 -2
- auto_rest_api-0.1.2/auto_rest/__main__.py +0 -94
- auto_rest_api-0.1.2/auto_rest/interfaces.py +0 -126
- auto_rest_api-0.1.2/auto_rest/params.py +0 -174
- {auto_rest_api-0.1.2 → auto_rest_api-0.1.4}/LICENSE.md +0 -0
- {auto_rest_api-0.1.2 → auto_rest_api-0.1.4}/auto_rest/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: auto-rest-api
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
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
|
|
@@ -34,7 +34,7 @@ Description-Content-Type: text/markdown
|
|
|
34
34
|
|
|
35
35
|
# Auto-REST
|
|
36
36
|
|
|
37
|
-
A
|
|
37
|
+
A light-weight CLI tool for deploying dynamically generated REST APIs against relational databases.
|
|
38
38
|
See the [project documentation](https://better-hpc.github.io/auto-rest/) for detailed usage instructions.
|
|
39
39
|
|
|
40
40
|
## Supported Databases
|
|
@@ -70,10 +70,6 @@ Launch an API by providing connection arguments to a database of your choice.
|
|
|
70
70
|
|
|
71
71
|
```shell
|
|
72
72
|
auto-rest \
|
|
73
|
-
# Enable optional endpoints / functionality
|
|
74
|
-
--enable-docs \
|
|
75
|
-
--enable-write \
|
|
76
|
-
# Define the database type and connection arguments
|
|
77
73
|
--psql
|
|
78
74
|
--db-host localhost
|
|
79
75
|
--db-port 5432
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Auto-REST
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A light-weight CLI tool for deploying dynamically generated REST APIs against relational databases.
|
|
4
4
|
See the [project documentation](https://better-hpc.github.io/auto-rest/) for detailed usage instructions.
|
|
5
5
|
|
|
6
6
|
## Supported Databases
|
|
@@ -36,10 +36,6 @@ Launch an API by providing connection arguments to a database of your choice.
|
|
|
36
36
|
|
|
37
37
|
```shell
|
|
38
38
|
auto-rest \
|
|
39
|
-
# Enable optional endpoints / functionality
|
|
40
|
-
--enable-docs \
|
|
41
|
-
--enable-write \
|
|
42
|
-
# Define the database type and connection arguments
|
|
43
39
|
--psql
|
|
44
40
|
--db-host localhost
|
|
45
41
|
--db-port 5432
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Application entrypoint triggered by calling the packaged CLI command."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from .app import *
|
|
6
|
+
from .cli import *
|
|
7
|
+
from .models import *
|
|
8
|
+
from .routers import *
|
|
9
|
+
|
|
10
|
+
__all__ = ["main", "run_application"]
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("auto-rest")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main() -> None: # pragma: no cover
|
|
16
|
+
"""Application entry point called when executing the command line interface.
|
|
17
|
+
|
|
18
|
+
This is a wrapper around the `run_application` function used to provide
|
|
19
|
+
graceful error handling.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
run_application()
|
|
24
|
+
|
|
25
|
+
except KeyboardInterrupt:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
except Exception as e:
|
|
29
|
+
logger.critical(str(e), exc_info=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_application(cli_args: list[str] = None, /) -> None: # pragma: no cover
|
|
33
|
+
"""Run an Auto-REST API server.
|
|
34
|
+
|
|
35
|
+
This function is equivalent to launching an API server from the command line
|
|
36
|
+
and accepts the same arguments as those provided in the CLI. Arguments are
|
|
37
|
+
parsed from STDIN by default, unless specified in the function call.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
A list of commandline arguments used to run the application.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
# Parse application arguments
|
|
44
|
+
args = create_cli_parser().parse_args(cli_args)
|
|
45
|
+
configure_cli_logging(args.log_level)
|
|
46
|
+
|
|
47
|
+
logger.info(f"Resolving database connection settings.")
|
|
48
|
+
db_kwargs = parse_db_settings(args.db_config)
|
|
49
|
+
db_url = create_db_url(
|
|
50
|
+
driver=args.db_driver,
|
|
51
|
+
host=args.db_host,
|
|
52
|
+
port=args.db_port,
|
|
53
|
+
database=args.db_name,
|
|
54
|
+
username=args.db_user,
|
|
55
|
+
password=args.db_pass
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
logger.info("Mapping database schema.")
|
|
59
|
+
db_conn = create_db_engine(db_url, **db_kwargs)
|
|
60
|
+
db_meta = create_db_metadata(db_conn)
|
|
61
|
+
|
|
62
|
+
logger.info("Creating application.")
|
|
63
|
+
app = create_app(args.app_title, args.app_version)
|
|
64
|
+
app.include_router(create_welcome_router(), prefix="")
|
|
65
|
+
app.include_router(create_meta_router(db_conn, db_meta, args.app_title, args.app_version), prefix="/meta")
|
|
66
|
+
for table_name, table in db_meta.tables.items():
|
|
67
|
+
app.include_router(create_table_router(db_conn, table), prefix=f"/db/{table_name}")
|
|
68
|
+
|
|
69
|
+
logger.info(f"Launching server on http://{args.server_host}:{args.server_port}.")
|
|
70
|
+
run_server(app, args.server_host, args.server_port)
|
|
@@ -8,20 +8,42 @@ deploying Fast-API applications.
|
|
|
8
8
|
```python
|
|
9
9
|
from auto_rest.app import create_app, run_server
|
|
10
10
|
|
|
11
|
-
app = create_app(app_title="My Application", app_version="1.2.3"
|
|
11
|
+
app = create_app(app_title="My Application", app_version="1.2.3")
|
|
12
12
|
... # Add endpoints to the application here
|
|
13
13
|
run_server(app, host="127.0.0.1", port=8081)
|
|
14
14
|
```
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
|
+
import logging
|
|
18
|
+
|
|
17
19
|
import uvicorn
|
|
18
|
-
from fastapi import FastAPI
|
|
20
|
+
from fastapi import FastAPI, Request
|
|
19
21
|
from fastapi.middleware.cors import CORSMiddleware
|
|
22
|
+
from starlette.responses import Response
|
|
20
23
|
|
|
21
24
|
__all__ = ["create_app", "run_server"]
|
|
22
25
|
|
|
26
|
+
logger = logging.getLogger("auto-rest")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def logging_middleware(request: Request, call_next: callable) -> Response:
|
|
30
|
+
"""FastAPI middleware for the logging response status codes.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
request: The incoming HTTP request.
|
|
34
|
+
call_next: The next middleware in the middleware chain.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
The outgoing HTTP response.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
response = await call_next(request)
|
|
41
|
+
level = logging.INFO if response.status_code < 400 else logging.ERROR
|
|
42
|
+
logger.log(level, f"{request.method} ({response.status_code}) {request.client.host} - {request.url.path}")
|
|
43
|
+
return response
|
|
23
44
|
|
|
24
|
-
|
|
45
|
+
|
|
46
|
+
def create_app(app_title: str, app_version: str) -> FastAPI:
|
|
25
47
|
"""Create and configure a FastAPI application instance.
|
|
26
48
|
|
|
27
49
|
This function initializes a FastAPI app with a customizable title, version,
|
|
@@ -31,7 +53,6 @@ def create_app(app_title: str, app_version: str, enable_docs: bool) -> FastAPI:
|
|
|
31
53
|
Args:
|
|
32
54
|
app_title: The title of the FastAPI application.
|
|
33
55
|
app_version: The version of the FastAPI application.
|
|
34
|
-
enable_docs: Whether to enable the `/docs/` endpoint.
|
|
35
56
|
|
|
36
57
|
Returns:
|
|
37
58
|
FastAPI: A configured FastAPI application instance.
|
|
@@ -40,14 +61,15 @@ def create_app(app_title: str, app_version: str, enable_docs: bool) -> FastAPI:
|
|
|
40
61
|
app = FastAPI(
|
|
41
62
|
title=app_title,
|
|
42
63
|
version=app_version,
|
|
43
|
-
docs_url="/docs/"
|
|
64
|
+
docs_url="/docs/",
|
|
44
65
|
redoc_url=None,
|
|
45
66
|
)
|
|
46
67
|
|
|
68
|
+
app.middleware("http")(logging_middleware)
|
|
47
69
|
app.add_middleware(
|
|
48
70
|
CORSMiddleware,
|
|
49
|
-
allow_origins=["*"],
|
|
50
71
|
allow_credentials=True,
|
|
72
|
+
allow_origins=["*"],
|
|
51
73
|
allow_methods=["*"],
|
|
52
74
|
allow_headers=["*"],
|
|
53
75
|
)
|
|
@@ -55,7 +77,7 @@ def create_app(app_title: str, app_version: str, enable_docs: bool) -> FastAPI:
|
|
|
55
77
|
return app
|
|
56
78
|
|
|
57
79
|
|
|
58
|
-
def run_server(app: FastAPI, host: str, port: int) -> None:
|
|
80
|
+
def run_server(app: FastAPI, host: str, port: int) -> None: # pragma: no cover
|
|
59
81
|
"""Deploy a FastAPI application server.
|
|
60
82
|
|
|
61
83
|
Args:
|
|
@@ -64,4 +86,5 @@ def run_server(app: FastAPI, host: str, port: int) -> None: # pragma: no cover
|
|
|
64
86
|
port: The port number for the server to listen on.
|
|
65
87
|
"""
|
|
66
88
|
|
|
67
|
-
|
|
89
|
+
# Uvicorn overwrites its logging level when run and needs to be manually disabled here.
|
|
90
|
+
uvicorn.run(app, host=host, port=port, log_level=1000)
|
|
@@ -62,11 +62,13 @@ def configure_cli_logging(level: str) -> None:
|
|
|
62
62
|
handler.setFormatter(DefaultFormatter(fmt="%(levelprefix)s %(message)s"))
|
|
63
63
|
logging.basicConfig(
|
|
64
64
|
force=True,
|
|
65
|
-
level=level,
|
|
66
65
|
format="%(levelprefix)s %(message)s",
|
|
67
66
|
handlers=[handler],
|
|
68
67
|
)
|
|
69
68
|
|
|
69
|
+
logging.getLogger("auto-rest").setLevel(level)
|
|
70
|
+
logging.getLogger("sqlalchemy").setLevel(1000)
|
|
71
|
+
|
|
70
72
|
|
|
71
73
|
def create_cli_parser(exit_on_error: bool = True) -> ArgumentParser:
|
|
72
74
|
"""Create a command-line argument parser with preconfigured arguments.
|
|
@@ -95,10 +97,6 @@ def create_cli_parser(exit_on_error: bool = True) -> ArgumentParser:
|
|
|
95
97
|
help="Set the logging level."
|
|
96
98
|
)
|
|
97
99
|
|
|
98
|
-
features = parser.add_argument_group(title="API features")
|
|
99
|
-
features.add_argument("--enable-docs", action="store_true", help="enable the 'docs' endpoint.")
|
|
100
|
-
features.add_argument("--enable-write", action="store_true", help="enable support for write operations.")
|
|
101
|
-
|
|
102
100
|
driver = parser.add_argument_group("database type")
|
|
103
101
|
db_type = driver.add_mutually_exclusive_group(required=True)
|
|
104
102
|
db_type.add_argument("--sqlite", action="store_const", dest="db_driver", const="sqlite+aiosqlite", help="use a SQLite database driver.")
|
|
@@ -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__ = [
|
|
@@ -76,7 +75,7 @@ __all__ = [
|
|
|
76
75
|
"create_welcome_handler",
|
|
77
76
|
]
|
|
78
77
|
|
|
79
|
-
logger = logging.getLogger(
|
|
78
|
+
logger = logging.getLogger("auto-rest")
|
|
80
79
|
|
|
81
80
|
|
|
82
81
|
def create_welcome_handler() -> Callable[[], Awaitable[PydanticModel]]:
|
|
@@ -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
|
-
|
|
200
|
-
|
|
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,
|
|
209
|
-
query = apply_ordering_params(query,
|
|
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)
|
|
@@ -34,6 +34,7 @@ import logging
|
|
|
34
34
|
from pathlib import Path
|
|
35
35
|
from typing import Callable
|
|
36
36
|
|
|
37
|
+
import yaml
|
|
37
38
|
from sqlalchemy import create_engine, Engine, MetaData, URL
|
|
38
39
|
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
|
|
39
40
|
from sqlalchemy.orm import Session
|
|
@@ -45,15 +46,34 @@ __all__ = [
|
|
|
45
46
|
"create_db_metadata",
|
|
46
47
|
"create_db_url",
|
|
47
48
|
"create_session_iterator",
|
|
49
|
+
"parse_db_settings"
|
|
48
50
|
]
|
|
49
51
|
|
|
50
|
-
logger = logging.getLogger(
|
|
52
|
+
logger = logging.getLogger("auto-rest")
|
|
51
53
|
|
|
52
54
|
# Base classes and typing objects.
|
|
53
55
|
DBEngine = Engine | AsyncEngine
|
|
54
56
|
DBSession = Session | AsyncSession
|
|
55
57
|
|
|
56
58
|
|
|
59
|
+
def parse_db_settings(path: Path | None) -> dict[str, any]:
|
|
60
|
+
"""Parse engine configuration settings from a given file path.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
path: Path to the configuration file.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Engine configuration settings.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
if path is not None:
|
|
70
|
+
logger.debug(f"Parsing engine configuration from {path}.")
|
|
71
|
+
return yaml.safe_load(path.read_text()) or dict()
|
|
72
|
+
|
|
73
|
+
logger.debug("No connection file specified.")
|
|
74
|
+
return {}
|
|
75
|
+
|
|
76
|
+
|
|
57
77
|
def create_db_url(
|
|
58
78
|
driver: str,
|
|
59
79
|
database: str,
|
|
@@ -76,21 +96,23 @@ def create_db_url(
|
|
|
76
96
|
A fully qualified database URL.
|
|
77
97
|
"""
|
|
78
98
|
|
|
79
|
-
logger.debug("Resolving database URL.")
|
|
80
|
-
|
|
81
99
|
# Handle special case where SQLite uses file paths.
|
|
82
100
|
if "sqlite" in driver:
|
|
83
101
|
path = Path(database).resolve()
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
102
|
+
url = URL.create(drivername=driver, database=str(path))
|
|
103
|
+
|
|
104
|
+
else:
|
|
105
|
+
url = URL.create(
|
|
106
|
+
drivername=driver,
|
|
107
|
+
username=username,
|
|
108
|
+
password=password,
|
|
109
|
+
host=host,
|
|
110
|
+
port=port,
|
|
111
|
+
database=database,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
logger.debug(f"Resolved URL: {url}")
|
|
115
|
+
return url
|
|
94
116
|
|
|
95
117
|
|
|
96
118
|
def create_db_engine(url: URL, **kwargs: dict[str: any]) -> DBEngine:
|
|
@@ -20,14 +20,18 @@ handling and provides a streamlined interface for database interactions.
|
|
|
20
20
|
```
|
|
21
21
|
"""
|
|
22
22
|
|
|
23
|
+
from typing import Literal
|
|
24
|
+
|
|
23
25
|
from fastapi import HTTPException
|
|
24
|
-
from sqlalchemy import Executable, Result
|
|
26
|
+
from sqlalchemy import asc, desc, Executable, Result, Select
|
|
25
27
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
26
28
|
from starlette import status
|
|
27
29
|
|
|
28
30
|
from auto_rest.models import DBSession
|
|
29
31
|
|
|
30
32
|
__all__ = [
|
|
33
|
+
"apply_ordering_params",
|
|
34
|
+
"apply_pagination_params",
|
|
31
35
|
"commit_session",
|
|
32
36
|
"delete_session_record",
|
|
33
37
|
"execute_session_query",
|
|
@@ -35,6 +39,64 @@ __all__ = [
|
|
|
35
39
|
]
|
|
36
40
|
|
|
37
41
|
|
|
42
|
+
def apply_ordering_params(
|
|
43
|
+
query: Select,
|
|
44
|
+
order_by: str | None = None,
|
|
45
|
+
direction: Literal["desc", "asc"] = "asc"
|
|
46
|
+
) -> Select:
|
|
47
|
+
"""Apply ordering to a database query.
|
|
48
|
+
|
|
49
|
+
Returns a copy of the provided query with ordering parameters applied.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
query: The database query to apply parameters to.
|
|
53
|
+
order_by: The name of the column to order by.
|
|
54
|
+
direction: The direction to order by (defaults to "asc").
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
A copy of the query modified to return ordered values.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
if order_by is None:
|
|
61
|
+
return query
|
|
62
|
+
|
|
63
|
+
if order_by not in query.columns:
|
|
64
|
+
raise ValueError(f"Invalid column name: {order_by}")
|
|
65
|
+
|
|
66
|
+
# Default to ascending order for an invalid ordering direction
|
|
67
|
+
if direction == "desc":
|
|
68
|
+
return query.order_by(desc(order_by))
|
|
69
|
+
|
|
70
|
+
elif direction == "asc":
|
|
71
|
+
return query.order_by(asc(order_by))
|
|
72
|
+
|
|
73
|
+
raise ValueError(f"Invalid direction, use 'asc' or 'desc': {direction}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def apply_pagination_params(query: Select, limit: int = 0, offset: int = 0) -> Select:
|
|
77
|
+
"""Apply pagination to a database query.
|
|
78
|
+
|
|
79
|
+
Returns a copy of the provided query with offset and limit parameters applied.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
query: The database query to apply parameters to.
|
|
83
|
+
limit: The number of results to return.
|
|
84
|
+
offset: The offset to start with.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
A copy of the query modified to only return the paginated values.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
if offset < 0 or limit < 0:
|
|
91
|
+
raise ValueError("Pagination parameters cannot be negative")
|
|
92
|
+
|
|
93
|
+
# Do not apply pagination if not requested
|
|
94
|
+
if limit == 0:
|
|
95
|
+
return query
|
|
96
|
+
|
|
97
|
+
return query.offset(offset or 0).limit(limit)
|
|
98
|
+
|
|
99
|
+
|
|
38
100
|
async def commit_session(session: DBSession) -> None:
|
|
39
101
|
"""Commit a SQLAlchemy session.
|
|
40
102
|
|
|
@@ -23,6 +23,8 @@ routers to be added directly to an API application instance.
|
|
|
23
23
|
```
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
|
+
import logging
|
|
27
|
+
|
|
26
28
|
from fastapi import APIRouter
|
|
27
29
|
from sqlalchemy import MetaData, Table
|
|
28
30
|
from starlette import status
|
|
@@ -36,6 +38,8 @@ __all__ = [
|
|
|
36
38
|
"create_welcome_router",
|
|
37
39
|
]
|
|
38
40
|
|
|
41
|
+
logger = logging.getLogger("auto-rest")
|
|
42
|
+
|
|
39
43
|
|
|
40
44
|
def create_welcome_router() -> APIRouter:
|
|
41
45
|
"""Create an API router for returning a welcome message.
|
|
@@ -44,6 +48,8 @@ def create_welcome_router() -> APIRouter:
|
|
|
44
48
|
An `APIRouter` with a single route for retrieving a welcome message.
|
|
45
49
|
"""
|
|
46
50
|
|
|
51
|
+
logger.debug("Creating welcome endpoint.")
|
|
52
|
+
|
|
47
53
|
router = APIRouter()
|
|
48
54
|
router.add_api_route(
|
|
49
55
|
path="/",
|
|
@@ -71,6 +77,8 @@ def create_meta_router(engine: DBEngine, metadata: MetaData, name: str, version:
|
|
|
71
77
|
An `APIRouter` with a routes for retrieving application metadata.
|
|
72
78
|
"""
|
|
73
79
|
|
|
80
|
+
logger.debug("Creating metadata endpoints.")
|
|
81
|
+
|
|
74
82
|
router = APIRouter()
|
|
75
83
|
tags = ["Application Metadata"]
|
|
76
84
|
|
|
@@ -101,25 +109,25 @@ def create_meta_router(engine: DBEngine, metadata: MetaData, name: str, version:
|
|
|
101
109
|
return router
|
|
102
110
|
|
|
103
111
|
|
|
104
|
-
def create_table_router(engine: DBEngine, table: Table
|
|
112
|
+
def create_table_router(engine: DBEngine, table: Table) -> APIRouter:
|
|
105
113
|
"""Create an API router with endpoint handlers for a given database table.
|
|
106
114
|
|
|
107
115
|
Args:
|
|
108
116
|
engine: The SQLAlchemy engine connected to the database.
|
|
109
117
|
table: The database table to create API endpoints for.
|
|
110
|
-
writeable: Whether the router should include support for write operations.
|
|
111
118
|
|
|
112
119
|
Returns:
|
|
113
120
|
An APIRouter instance with routes for database operations on the table.
|
|
114
121
|
"""
|
|
115
122
|
|
|
123
|
+
logger.debug(f"Creating endpoints for table `{table.name}`.")
|
|
116
124
|
router = APIRouter()
|
|
117
125
|
|
|
118
126
|
# Construct path parameters from primary key columns
|
|
119
127
|
pk_columns = sorted(column.name for column in table.primary_key.columns)
|
|
120
128
|
path_params_url = "/".join(f"{{{col_name}}}" for col_name in pk_columns)
|
|
121
129
|
|
|
122
|
-
# Add
|
|
130
|
+
# Add routes for operations against the table
|
|
123
131
|
router.add_api_route(
|
|
124
132
|
path="/",
|
|
125
133
|
methods=["GET"],
|
|
@@ -129,16 +137,14 @@ def create_table_router(engine: DBEngine, table: Table, writeable: bool = False)
|
|
|
129
137
|
tags=[table.name],
|
|
130
138
|
)
|
|
131
139
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
tags=[table.name],
|
|
141
|
-
)
|
|
140
|
+
router.add_api_route(
|
|
141
|
+
path="/",
|
|
142
|
+
methods=["POST"],
|
|
143
|
+
endpoint=create_post_record_handler(engine, table),
|
|
144
|
+
status_code=status.HTTP_201_CREATED,
|
|
145
|
+
summary="Create a new record.",
|
|
146
|
+
tags=[table.name],
|
|
147
|
+
)
|
|
142
148
|
|
|
143
149
|
# Add route for read operations against individual records
|
|
144
150
|
if pk_columns:
|
|
@@ -151,8 +157,6 @@ def create_table_router(engine: DBEngine, table: Table, writeable: bool = False)
|
|
|
151
157
|
tags=[table.name],
|
|
152
158
|
)
|
|
153
159
|
|
|
154
|
-
# Add routes for write operations against individual records
|
|
155
|
-
if pk_columns and writeable:
|
|
156
160
|
router.add_api_route(
|
|
157
161
|
path=f"/{path_params_url}/",
|
|
158
162
|
methods=["PUT"],
|
|
@@ -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.
|
|
7
|
+
version = "0.1.4"
|
|
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,94 +0,0 @@
|
|
|
1
|
-
"""Application entrypoint triggered by calling the packaged CLI command."""
|
|
2
|
-
|
|
3
|
-
import logging
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
import yaml
|
|
7
|
-
|
|
8
|
-
from .app import *
|
|
9
|
-
from .cli import *
|
|
10
|
-
from .models import *
|
|
11
|
-
from .routers import *
|
|
12
|
-
|
|
13
|
-
__all__ = ["main", "run_application"]
|
|
14
|
-
|
|
15
|
-
logger = logging.getLogger(__name__)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def main() -> None: # pragma: no cover
|
|
19
|
-
"""Parse command-line arguments and launch an API server."""
|
|
20
|
-
|
|
21
|
-
try:
|
|
22
|
-
parser = create_cli_parser()
|
|
23
|
-
args = vars(parser.parse_args())
|
|
24
|
-
log_level = args.pop("log_level")
|
|
25
|
-
|
|
26
|
-
configure_cli_logging(log_level)
|
|
27
|
-
run_application(**args)
|
|
28
|
-
|
|
29
|
-
except KeyboardInterrupt:
|
|
30
|
-
pass
|
|
31
|
-
|
|
32
|
-
except Exception as e:
|
|
33
|
-
logger.critical(str(e), exc_info=True)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def run_application(
|
|
37
|
-
enable_docs: bool,
|
|
38
|
-
enable_write: bool,
|
|
39
|
-
db_driver: str,
|
|
40
|
-
db_host: str,
|
|
41
|
-
db_port: int,
|
|
42
|
-
db_name: str,
|
|
43
|
-
db_user: str,
|
|
44
|
-
db_pass: str,
|
|
45
|
-
db_config: Path | None,
|
|
46
|
-
server_host: str,
|
|
47
|
-
server_port: int,
|
|
48
|
-
app_title: str,
|
|
49
|
-
app_version: str,
|
|
50
|
-
) -> None: # pragma: no cover
|
|
51
|
-
"""Run an Auto-REST API server.
|
|
52
|
-
|
|
53
|
-
This function is equivalent to launching an API server from the command line
|
|
54
|
-
and accepts the same arguments as those provided in the CLI.
|
|
55
|
-
|
|
56
|
-
Args:
|
|
57
|
-
enable_docs: Whether to enable the 'docs' API endpoint.
|
|
58
|
-
enable_write: Whether to enable support for write operations.
|
|
59
|
-
db_driver: SQLAlchemy-compatible database driver.
|
|
60
|
-
db_host: Database host address.
|
|
61
|
-
db_port: Database port number.
|
|
62
|
-
db_name: Database name.
|
|
63
|
-
db_user: Database authentication username.
|
|
64
|
-
db_pass: Database authentication password.
|
|
65
|
-
db_config: Path to a database configuration file.
|
|
66
|
-
server_host: API server host address.
|
|
67
|
-
server_port: API server port number.
|
|
68
|
-
app_title: title for the generated OpenAPI schema.
|
|
69
|
-
app_version: version number for the generated OpenAPI schema.
|
|
70
|
-
"""
|
|
71
|
-
|
|
72
|
-
logger.info(f"Mapping database schema for {db_name}.")
|
|
73
|
-
|
|
74
|
-
# Resolve database connection settings
|
|
75
|
-
db_url = create_db_url(driver=db_driver, host=db_host, port=db_port, database=db_name, username=db_user, password=db_pass)
|
|
76
|
-
db_kwargs = yaml.safe_load(db_config.read_text()) if db_config else {}
|
|
77
|
-
|
|
78
|
-
# Connect to and map the database.
|
|
79
|
-
db_conn = create_db_engine(db_url, **db_kwargs)
|
|
80
|
-
db_meta = create_db_metadata(db_conn)
|
|
81
|
-
|
|
82
|
-
# Build an empty application and dynamically add the requested functionality.
|
|
83
|
-
logger.info("Creating API application.")
|
|
84
|
-
app = create_app(app_title, app_version, enable_docs)
|
|
85
|
-
app.include_router(create_welcome_router(), prefix="")
|
|
86
|
-
app.include_router(create_meta_router(db_conn, db_meta, app_title, app_version), prefix="/meta")
|
|
87
|
-
|
|
88
|
-
for table_name, table in db_meta.tables.items():
|
|
89
|
-
logger.info(f"Adding `/db/{table_name}` endpoint.")
|
|
90
|
-
app.include_router(create_table_router(db_conn, table, enable_write), prefix=f"/db/{table_name}")
|
|
91
|
-
|
|
92
|
-
# Launch the API server.
|
|
93
|
-
logger.info(f"Launching API server on http://{server_host}:{server_port}.")
|
|
94
|
-
run_server(app, server_host, server_port)
|
|
@@ -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
|