auto-rest-api 0.1.3__py3-none-any.whl → 0.1.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of auto-rest-api might be problematic. Click here for more details.
- auto_rest/__main__.py +33 -57
- auto_rest/app.py +52 -8
- auto_rest/cli.py +54 -18
- auto_rest/handlers.py +18 -11
- auto_rest/models.py +34 -14
- auto_rest/queries.py +6 -61
- auto_rest/routers.py +22 -18
- {auto_rest_api-0.1.3.dist-info → auto_rest_api-0.1.5.dist-info}/METADATA +5 -7
- auto_rest_api-0.1.5.dist-info/RECORD +14 -0
- {auto_rest_api-0.1.3.dist-info → auto_rest_api-0.1.5.dist-info}/WHEEL +1 -1
- auto_rest_api-0.1.3.dist-info/RECORD +0 -14
- {auto_rest_api-0.1.3.dist-info → auto_rest_api-0.1.5.dist-info}/LICENSE.md +0 -0
- {auto_rest_api-0.1.3.dist-info → auto_rest_api-0.1.5.dist-info}/entry_points.txt +0 -0
auto_rest/__main__.py
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
"""Application entrypoint triggered by calling the packaged CLI command."""
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
import yaml
|
|
7
4
|
|
|
8
5
|
from .app import *
|
|
9
6
|
from .cli import *
|
|
@@ -12,19 +9,18 @@ from .routers import *
|
|
|
12
9
|
|
|
13
10
|
__all__ = ["main", "run_application"]
|
|
14
11
|
|
|
15
|
-
logger = logging.getLogger(
|
|
12
|
+
logger = logging.getLogger("auto_rest")
|
|
16
13
|
|
|
17
14
|
|
|
18
15
|
def main() -> None: # pragma: no cover
|
|
19
|
-
"""
|
|
16
|
+
"""Application entry point called when executing the command line interface.
|
|
20
17
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
log_level = args.pop("log_level")
|
|
18
|
+
This is a wrapper around the `run_application` function used to provide
|
|
19
|
+
graceful error handling.
|
|
20
|
+
"""
|
|
25
21
|
|
|
26
|
-
|
|
27
|
-
run_application(
|
|
22
|
+
try:
|
|
23
|
+
run_application()
|
|
28
24
|
|
|
29
25
|
except KeyboardInterrupt:
|
|
30
26
|
pass
|
|
@@ -33,62 +29,42 @@ def main() -> None: # pragma: no cover
|
|
|
33
29
|
logger.critical(str(e), exc_info=True)
|
|
34
30
|
|
|
35
31
|
|
|
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
|
|
32
|
+
def run_application(cli_args: list[str] = None, /) -> None: # pragma: no cover
|
|
51
33
|
"""Run an Auto-REST API server.
|
|
52
34
|
|
|
53
35
|
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.
|
|
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.
|
|
55
38
|
|
|
56
39
|
Args:
|
|
57
|
-
|
|
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.
|
|
40
|
+
A list of commandline arguments used to run the application.
|
|
70
41
|
"""
|
|
71
42
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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.")
|
|
79
59
|
db_conn = create_db_engine(db_url, **db_kwargs)
|
|
80
60
|
db_meta = create_db_metadata(db_conn)
|
|
81
61
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
app = create_app(app_title, app_version, enable_docs)
|
|
62
|
+
logger.info("Creating application.")
|
|
63
|
+
app = create_app(args.app_title, args.app_version)
|
|
85
64
|
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
|
-
|
|
65
|
+
app.include_router(create_meta_router(db_conn, db_meta, args.app_title, args.app_version), prefix="/meta")
|
|
88
66
|
for table_name, table in db_meta.tables.items():
|
|
89
|
-
|
|
90
|
-
app.include_router(create_table_router(db_conn, table, enable_write), prefix=f"/db/{table_name}")
|
|
67
|
+
app.include_router(create_table_router(db_conn, table), prefix=f"/db/{table_name}")
|
|
91
68
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
run_server(app, server_host, server_port)
|
|
69
|
+
logger.info(f"Launching server on http://{args.server_host}:{args.server_port}.")
|
|
70
|
+
run_server(app, args.server_host, args.server_port)
|
auto_rest/app.py
CHANGED
|
@@ -8,20 +8,62 @@ 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
|
+
from http import HTTPStatus
|
|
19
|
+
|
|
17
20
|
import uvicorn
|
|
18
|
-
from
|
|
21
|
+
from asgi_correlation_id import CorrelationIdMiddleware
|
|
22
|
+
from fastapi import FastAPI, Request
|
|
19
23
|
from fastapi.middleware.cors import CORSMiddleware
|
|
24
|
+
from starlette.responses import Response
|
|
20
25
|
|
|
21
26
|
__all__ = ["create_app", "run_server"]
|
|
22
27
|
|
|
28
|
+
logger = logging.getLogger("auto_rest.access")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def logging_middleware(request: Request, call_next: callable) -> Response:
|
|
32
|
+
"""FastAPI middleware for logging response status codes.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
request: The incoming HTTP request.
|
|
36
|
+
call_next: The next middleware in the middleware chain.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The outgoing HTTP response.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
# Extract metadata from the request
|
|
43
|
+
request_meta = {
|
|
44
|
+
"ip": request.client.host,
|
|
45
|
+
"port": request.client.port,
|
|
46
|
+
"method": request.method,
|
|
47
|
+
"endpoint": f"{request.url.path}?{request.url.query}",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Execute handling logic
|
|
51
|
+
try:
|
|
52
|
+
response = await call_next(request)
|
|
53
|
+
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
logger.error(str(exec), exc_info=exc, extra=request_meta)
|
|
56
|
+
raise
|
|
57
|
+
|
|
58
|
+
# Log the outgoing response
|
|
59
|
+
status = HTTPStatus(response.status_code)
|
|
60
|
+
level = logging.INFO if status < 400 else logging.ERROR
|
|
61
|
+
logger.log(level, f"{status} {status.phrase}", extra=request_meta)
|
|
23
62
|
|
|
24
|
-
|
|
63
|
+
return response
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def create_app(app_title: str, app_version: str) -> FastAPI:
|
|
25
67
|
"""Create and configure a FastAPI application instance.
|
|
26
68
|
|
|
27
69
|
This function initializes a FastAPI app with a customizable title, version,
|
|
@@ -31,7 +73,6 @@ def create_app(app_title: str, app_version: str, enable_docs: bool) -> FastAPI:
|
|
|
31
73
|
Args:
|
|
32
74
|
app_title: The title of the FastAPI application.
|
|
33
75
|
app_version: The version of the FastAPI application.
|
|
34
|
-
enable_docs: Whether to enable the `/docs/` endpoint.
|
|
35
76
|
|
|
36
77
|
Returns:
|
|
37
78
|
FastAPI: A configured FastAPI application instance.
|
|
@@ -40,14 +81,16 @@ def create_app(app_title: str, app_version: str, enable_docs: bool) -> FastAPI:
|
|
|
40
81
|
app = FastAPI(
|
|
41
82
|
title=app_title,
|
|
42
83
|
version=app_version,
|
|
43
|
-
docs_url="/docs/"
|
|
84
|
+
docs_url="/docs/",
|
|
44
85
|
redoc_url=None,
|
|
45
86
|
)
|
|
46
87
|
|
|
88
|
+
app.middleware("http")(logging_middleware)
|
|
89
|
+
app.add_middleware(CorrelationIdMiddleware)
|
|
47
90
|
app.add_middleware(
|
|
48
91
|
CORSMiddleware,
|
|
49
|
-
allow_origins=["*"],
|
|
50
92
|
allow_credentials=True,
|
|
93
|
+
allow_origins=["*"],
|
|
51
94
|
allow_methods=["*"],
|
|
52
95
|
allow_headers=["*"],
|
|
53
96
|
)
|
|
@@ -55,7 +98,7 @@ def create_app(app_title: str, app_version: str, enable_docs: bool) -> FastAPI:
|
|
|
55
98
|
return app
|
|
56
99
|
|
|
57
100
|
|
|
58
|
-
def run_server(app: FastAPI, host: str, port: int) -> None:
|
|
101
|
+
def run_server(app: FastAPI, host: str, port: int) -> None: # pragma: no cover
|
|
59
102
|
"""Deploy a FastAPI application server.
|
|
60
103
|
|
|
61
104
|
Args:
|
|
@@ -64,4 +107,5 @@ def run_server(app: FastAPI, host: str, port: int) -> None: # pragma: no cover
|
|
|
64
107
|
port: The port number for the server to listen on.
|
|
65
108
|
"""
|
|
66
109
|
|
|
67
|
-
|
|
110
|
+
# Uvicorn overwrites its logging level when run and needs to be manually disabled here.
|
|
111
|
+
uvicorn.run(app, host=host, port=port, log_level=1000)
|
auto_rest/cli.py
CHANGED
|
@@ -31,13 +31,11 @@ Python `logging` library.
|
|
|
31
31
|
"""
|
|
32
32
|
|
|
33
33
|
import importlib.metadata
|
|
34
|
-
import logging
|
|
34
|
+
import logging.config
|
|
35
35
|
from argparse import ArgumentParser, HelpFormatter
|
|
36
36
|
from pathlib import Path
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
__all__ = ["VERSION", "configure_cli_logging", "create_cli_parser"]
|
|
38
|
+
__all__ = ["configure_cli_logging", "create_cli_parser"]
|
|
41
39
|
|
|
42
40
|
VERSION = importlib.metadata.version("auto-rest-api")
|
|
43
41
|
|
|
@@ -49,7 +47,7 @@ def configure_cli_logging(level: str) -> None:
|
|
|
49
47
|
logging configurations.
|
|
50
48
|
|
|
51
49
|
Args:
|
|
52
|
-
level: The Python logging level.
|
|
50
|
+
level: The Python logging level (e.g., "DEBUG", "INFO", etc.).
|
|
53
51
|
"""
|
|
54
52
|
|
|
55
53
|
# Normalize and validate the logging level.
|
|
@@ -57,15 +55,57 @@ def configure_cli_logging(level: str) -> None:
|
|
|
57
55
|
if level not in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
|
|
58
56
|
raise ValueError(f"Invalid logging level: {level}")
|
|
59
57
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
58
|
+
msg_prefix = "%(log_color)s%(levelname)-8s%(reset)s (%(asctime)s) [%(correlation_id)s] "
|
|
59
|
+
logging.config.dictConfig({
|
|
60
|
+
"version": 1,
|
|
61
|
+
"disable_existing_loggers": True,
|
|
62
|
+
"filters": {
|
|
63
|
+
"correlation_id": {
|
|
64
|
+
"()": "asgi_correlation_id.CorrelationIdFilter",
|
|
65
|
+
"uuid_length": 8,
|
|
66
|
+
"default_value": "-" * 8
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
"formatters": {
|
|
70
|
+
"app": {
|
|
71
|
+
"()": "colorlog.ColoredFormatter",
|
|
72
|
+
"format": msg_prefix + "%(message)s",
|
|
73
|
+
},
|
|
74
|
+
"access": {
|
|
75
|
+
"()": "colorlog.ColoredFormatter",
|
|
76
|
+
"format": msg_prefix + "%(ip)s:%(port)s - %(method)s %(endpoint)s - %(message)s",
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"handlers": {
|
|
80
|
+
"app": {
|
|
81
|
+
"class": "colorlog.StreamHandler",
|
|
82
|
+
"formatter": "app",
|
|
83
|
+
"filters": ["correlation_id"],
|
|
84
|
+
},
|
|
85
|
+
"access": {
|
|
86
|
+
"class": "colorlog.StreamHandler",
|
|
87
|
+
"formatter": "access",
|
|
88
|
+
"filters": ["correlation_id"],
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"loggers": {
|
|
92
|
+
"auto_rest": {
|
|
93
|
+
"handlers": ["app"],
|
|
94
|
+
"level": level,
|
|
95
|
+
"propagate": False
|
|
96
|
+
},
|
|
97
|
+
"auto_rest.access": {
|
|
98
|
+
"handlers": ["access"],
|
|
99
|
+
"level": level,
|
|
100
|
+
"propagate": False
|
|
101
|
+
},
|
|
102
|
+
"auto_rest.query": {
|
|
103
|
+
"handlers": ["app"],
|
|
104
|
+
"level": level,
|
|
105
|
+
"propagate": False
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
})
|
|
69
109
|
|
|
70
110
|
|
|
71
111
|
def create_cli_parser(exit_on_error: bool = True) -> ArgumentParser:
|
|
@@ -95,10 +135,6 @@ def create_cli_parser(exit_on_error: bool = True) -> ArgumentParser:
|
|
|
95
135
|
help="Set the logging level."
|
|
96
136
|
)
|
|
97
137
|
|
|
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
138
|
driver = parser.add_argument_group("database type")
|
|
103
139
|
db_type = driver.add_mutually_exclusive_group(required=True)
|
|
104
140
|
db_type.add_argument("--sqlite", action="store_const", dest="db_driver", const="sqlite+aiosqlite", help="use a SQLite database driver.")
|
auto_rest/handlers.py
CHANGED
|
@@ -50,13 +50,12 @@ This makes it easy to incorporate handlers into a FastAPI application.
|
|
|
50
50
|
should always be tested within the context of a FastAPI application.
|
|
51
51
|
"""
|
|
52
52
|
|
|
53
|
-
import logging
|
|
54
53
|
from typing import Awaitable, Callable, Literal, Optional
|
|
55
54
|
|
|
56
55
|
from fastapi import Depends, Query, Response
|
|
57
56
|
from pydantic import create_model
|
|
58
57
|
from pydantic.main import BaseModel as PydanticModel
|
|
59
|
-
from sqlalchemy import insert, MetaData, select, Table
|
|
58
|
+
from sqlalchemy import asc, desc, func, insert, MetaData, select, Table
|
|
60
59
|
|
|
61
60
|
from .interfaces import *
|
|
62
61
|
from .models import *
|
|
@@ -75,8 +74,6 @@ __all__ = [
|
|
|
75
74
|
"create_welcome_handler",
|
|
76
75
|
]
|
|
77
76
|
|
|
78
|
-
logger = logging.getLogger(__name__)
|
|
79
|
-
|
|
80
77
|
|
|
81
78
|
def create_welcome_handler() -> Callable[[], Awaitable[PydanticModel]]:
|
|
82
79
|
"""Create an endpoint handler that returns an application welcome message.
|
|
@@ -191,7 +188,6 @@ def create_list_records_handler(engine: DBEngine, table: Table) -> Callable[...,
|
|
|
191
188
|
"""
|
|
192
189
|
|
|
193
190
|
interface = create_interface(table)
|
|
194
|
-
interface_opt = create_interface(table, mode="optional")
|
|
195
191
|
columns = tuple(table.columns.keys())
|
|
196
192
|
|
|
197
193
|
async def list_records_handler(
|
|
@@ -207,14 +203,25 @@ def create_list_records_handler(engine: DBEngine, table: Table) -> Callable[...,
|
|
|
207
203
|
URL query parameters are used to enable filtering, ordering, and paginating returned values.
|
|
208
204
|
"""
|
|
209
205
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
206
|
+
# Determine total record count
|
|
207
|
+
total_count_query = select(func.count()).select_from(table)
|
|
208
|
+
total_count = await execute_session_query(session, total_count_query)
|
|
209
|
+
|
|
210
|
+
# Set headers
|
|
211
|
+
response.headers["x-pagination-limit"] = str(_limit_)
|
|
212
|
+
response.headers["x-pagination-offset"] = str(_offset_)
|
|
213
|
+
response.headers["x-pagination-total"] = str(total_count.first()[0])
|
|
214
|
+
response.headers["x-order-by"] = str(_order_by_)
|
|
215
|
+
response.headers["x-order-direction"] = str(_direction_)
|
|
214
216
|
|
|
217
|
+
# Fetch data per the request parameters
|
|
215
218
|
query = select(table)
|
|
216
|
-
|
|
217
|
-
|
|
219
|
+
if _limit_ > 0:
|
|
220
|
+
query = query.offset(_offset_).limit(_limit_)
|
|
221
|
+
|
|
222
|
+
if _order_by_ is not None:
|
|
223
|
+
direction = {'desc': desc, 'asc': asc}[_direction_]
|
|
224
|
+
query = query.order_by(direction(_order_by_))
|
|
218
225
|
|
|
219
226
|
result = await execute_session_query(session, query)
|
|
220
227
|
return [row._mapping for row in result.all()]
|
auto_rest/models.py
CHANGED
|
@@ -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 configuration 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
|
-
|
|
102
|
+
url = URL.create(drivername=driver, database=str(path))
|
|
85
103
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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:
|
|
@@ -107,8 +129,6 @@ def create_db_engine(url: URL, **kwargs: dict[str: any]) -> DBEngine:
|
|
|
107
129
|
A SQLAlchemy `Engine` or `AsyncEngine` instance.
|
|
108
130
|
"""
|
|
109
131
|
|
|
110
|
-
logger.debug(f"Building database engine for {url}.")
|
|
111
|
-
|
|
112
132
|
if url.get_dialect().is_async:
|
|
113
133
|
engine = create_async_engine(url, **kwargs)
|
|
114
134
|
logger.debug("Asynchronous connection established.")
|
auto_rest/queries.py
CHANGED
|
@@ -19,81 +19,24 @@ handling and provides a streamlined interface for database interactions.
|
|
|
19
19
|
result = await execute_session_query(async_session, query)
|
|
20
20
|
```
|
|
21
21
|
"""
|
|
22
|
-
|
|
22
|
+
|
|
23
|
+
import logging
|
|
23
24
|
|
|
24
25
|
from fastapi import HTTPException
|
|
25
|
-
from sqlalchemy import
|
|
26
|
+
from sqlalchemy import Executable, Result
|
|
26
27
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
27
28
|
from starlette import status
|
|
28
29
|
|
|
29
30
|
from auto_rest.models import DBSession
|
|
30
31
|
|
|
31
32
|
__all__ = [
|
|
32
|
-
"apply_ordering_params",
|
|
33
|
-
"apply_pagination_params",
|
|
34
33
|
"commit_session",
|
|
35
34
|
"delete_session_record",
|
|
36
35
|
"execute_session_query",
|
|
37
36
|
"get_record_or_404"
|
|
38
37
|
]
|
|
39
38
|
|
|
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)
|
|
39
|
+
logger = logging.getLogger("auto_rest.query")
|
|
97
40
|
|
|
98
41
|
|
|
99
42
|
async def commit_session(session: DBSession) -> None:
|
|
@@ -123,6 +66,7 @@ async def delete_session_record(session: DBSession, record: Result) -> None:
|
|
|
123
66
|
record: The record to be deleted.
|
|
124
67
|
"""
|
|
125
68
|
|
|
69
|
+
logger.debug("Deleting record.")
|
|
126
70
|
if isinstance(session, AsyncSession):
|
|
127
71
|
await session.delete(record)
|
|
128
72
|
|
|
@@ -143,6 +87,7 @@ async def execute_session_query(session: DBSession, query: Executable) -> Result
|
|
|
143
87
|
The result of the executed query.
|
|
144
88
|
"""
|
|
145
89
|
|
|
90
|
+
logger.debug(str(query).replace("\n", " "))
|
|
146
91
|
if isinstance(session, AsyncSession):
|
|
147
92
|
return await session.execute(query)
|
|
148
93
|
|
auto_rest/routers.py
CHANGED
|
@@ -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,11 +77,13 @@ 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
|
|
|
77
85
|
router.add_api_route(
|
|
78
|
-
path="/app",
|
|
86
|
+
path="/app/",
|
|
79
87
|
methods=["GET"],
|
|
80
88
|
endpoint=create_about_handler(name, version),
|
|
81
89
|
summary="Fetch application metadata.",
|
|
@@ -83,7 +91,7 @@ def create_meta_router(engine: DBEngine, metadata: MetaData, name: str, version:
|
|
|
83
91
|
)
|
|
84
92
|
|
|
85
93
|
router.add_api_route(
|
|
86
|
-
path="/engine",
|
|
94
|
+
path="/engine/",
|
|
87
95
|
methods=["GET"],
|
|
88
96
|
endpoint=create_engine_handler(engine),
|
|
89
97
|
summary="Fetch database metadata.",
|
|
@@ -91,7 +99,7 @@ def create_meta_router(engine: DBEngine, metadata: MetaData, name: str, version:
|
|
|
91
99
|
)
|
|
92
100
|
|
|
93
101
|
router.add_api_route(
|
|
94
|
-
path="/schema",
|
|
102
|
+
path="/schema/",
|
|
95
103
|
methods=["GET"],
|
|
96
104
|
endpoint=create_schema_handler(metadata),
|
|
97
105
|
summary="Fetch the database schema.",
|
|
@@ -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"],
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: auto-rest-api
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
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
|
|
7
7
|
Author: Better HPC LLC
|
|
8
|
-
Requires-Python: >=3.11
|
|
8
|
+
Requires-Python: >=3.11,<4
|
|
9
9
|
Classifier: Environment :: Web Environment
|
|
10
10
|
Classifier: Intended Audience :: Developers
|
|
11
11
|
Classifier: Intended Audience :: Information Technology
|
|
@@ -21,7 +21,9 @@ Classifier: Typing :: Typed
|
|
|
21
21
|
Requires-Dist: aiomysql (>=0.2,<1.0)
|
|
22
22
|
Requires-Dist: aioodbc (>=0.5,<1.0)
|
|
23
23
|
Requires-Dist: aiosqlite (>=0.20,<1.0)
|
|
24
|
+
Requires-Dist: asgi-correlation-id (>=4.3.4,<5.0.0)
|
|
24
25
|
Requires-Dist: asyncpg (>=0.30,<1.0)
|
|
26
|
+
Requires-Dist: colorlog (>=6.9.0,<7.0.0)
|
|
25
27
|
Requires-Dist: fastapi (>=0.115,<1.0)
|
|
26
28
|
Requires-Dist: greenlet (>=3.1,<4.0)
|
|
27
29
|
Requires-Dist: httpx (>=0.28,<1.0)
|
|
@@ -34,7 +36,7 @@ Description-Content-Type: text/markdown
|
|
|
34
36
|
|
|
35
37
|
# Auto-REST
|
|
36
38
|
|
|
37
|
-
A
|
|
39
|
+
A light-weight CLI tool for deploying dynamically generated REST APIs against relational databases.
|
|
38
40
|
See the [project documentation](https://better-hpc.github.io/auto-rest/) for detailed usage instructions.
|
|
39
41
|
|
|
40
42
|
## Supported Databases
|
|
@@ -70,10 +72,6 @@ Launch an API by providing connection arguments to a database of your choice.
|
|
|
70
72
|
|
|
71
73
|
```shell
|
|
72
74
|
auto-rest \
|
|
73
|
-
# Enable optional endpoints / functionality
|
|
74
|
-
--enable-docs \
|
|
75
|
-
--enable-write \
|
|
76
|
-
# Define the database type and connection arguments
|
|
77
75
|
--psql
|
|
78
76
|
--db-host localhost
|
|
79
77
|
--db-port 5432
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
auto_rest/__init__.py,sha256=9ICmv2urSoAo856FJylKdorF19UsUGc4eyORYLptf1Q,69
|
|
2
|
+
auto_rest/__main__.py,sha256=VkLZ9j1Q9I0isYvO0Bj1HHCQ9Ew_r5O7a0Dn4fUi5Lo,2233
|
|
3
|
+
auto_rest/app.py,sha256=AnBb2jt0yMxTLj_ICWB1mXLgu2pchjlapfdIk2CwNsQ,3128
|
|
4
|
+
auto_rest/cli.py,sha256=3emee1GrUkQZ-DVAboy5O65jPE0vwmdcy-wfaxh2kBs,6239
|
|
5
|
+
auto_rest/handlers.py,sha256=gwMiE5TKA65nrMizQ472A5iCNmYK7B7vlFDWL0Vrphw,12943
|
|
6
|
+
auto_rest/interfaces.py,sha256=WB_0eMDjGF8DpnDN9INHqo7u4x3aklvzAYK4t3JwC7s,3779
|
|
7
|
+
auto_rest/models.py,sha256=pi97l2PsDqjhW52tAIAZKx6bl2QvC-q5kM5OsVP3V_M,5887
|
|
8
|
+
auto_rest/queries.py,sha256=2e6f_S4n1xvaSY8rYJG-koDmqiXHreOIRUteRuqZlv8,3058
|
|
9
|
+
auto_rest/routers.py,sha256=SZoOugauKdCk2Kqzzp9o6tGENusAsP1-YP-6Aam53tc,5636
|
|
10
|
+
auto_rest_api-0.1.5.dist-info/LICENSE.md,sha256=zFRw_u1mGSOH8GrpOu0L1P765aX9fB5UpKz06mTxAos,34893
|
|
11
|
+
auto_rest_api-0.1.5.dist-info/METADATA,sha256=cgh43Yv81K8gaf9B-mAAiWX72VnJAGGuQHWa7Qi5-K4,2914
|
|
12
|
+
auto_rest_api-0.1.5.dist-info/WHEEL,sha256=RaoafKOydTQ7I_I3JTrPCg6kUmTgtm4BornzOqyEfJ8,88
|
|
13
|
+
auto_rest_api-0.1.5.dist-info/entry_points.txt,sha256=zFynmBrHyYo3Ds0Uo4-bTFe1Tdr5mIXV4dPQOFb-W1w,53
|
|
14
|
+
auto_rest_api-0.1.5.dist-info/RECORD,,
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
auto_rest/__init__.py,sha256=9ICmv2urSoAo856FJylKdorF19UsUGc4eyORYLptf1Q,69
|
|
2
|
-
auto_rest/__main__.py,sha256=lz6-LEpbu51Ah3QCzUzZhevjWhK5kmOxhuuqhX5t-io,3093
|
|
3
|
-
auto_rest/app.py,sha256=I6ZeHzKuhPTS1svLhMTvefjzTkMJgGpwVz1kdJ_9mtE,1887
|
|
4
|
-
auto_rest/cli.py,sha256=A7-kMTNNzqZB7jTUJBAVqfYm9RyYjENr0g_vK9JE0N4,5199
|
|
5
|
-
auto_rest/handlers.py,sha256=Z8DMv3KRYLBXlUKoE4e-2qE9YvG-IPgS57C0RpUDWv4,12603
|
|
6
|
-
auto_rest/interfaces.py,sha256=WB_0eMDjGF8DpnDN9INHqo7u4x3aklvzAYK4t3JwC7s,3779
|
|
7
|
-
auto_rest/models.py,sha256=HCJUQPBmkkfVFv9CRcPzMP66pQk_UIb3j3cdwHqodwE,5388
|
|
8
|
-
auto_rest/queries.py,sha256=Z2ATkcSYldV6BkcrmLogmBoDkPEkyFzFqI7qPcq86uc,4705
|
|
9
|
-
auto_rest/routers.py,sha256=RqBwLqVFU1OIFSCUuwOtTis8mT_zyWTaB_8SyIXjfe0,5727
|
|
10
|
-
auto_rest_api-0.1.3.dist-info/LICENSE.md,sha256=zFRw_u1mGSOH8GrpOu0L1P765aX9fB5UpKz06mTxAos,34893
|
|
11
|
-
auto_rest_api-0.1.3.dist-info/METADATA,sha256=Cs7u-2jZGTTlvBprPzdLVWl1uSAAjjLgeh5PuHo3tUk,2951
|
|
12
|
-
auto_rest_api-0.1.3.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
|
13
|
-
auto_rest_api-0.1.3.dist-info/entry_points.txt,sha256=zFynmBrHyYo3Ds0Uo4-bTFe1Tdr5mIXV4dPQOFb-W1w,53
|
|
14
|
-
auto_rest_api-0.1.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|