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.

@@ -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.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 command line tool for deploying dynamically generated REST APIs against relational databases.
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 command line tool for deploying dynamically generated REST APIs against relational databases.
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", enable_docs=True)
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
- def create_app(app_title: str, app_version: str, enable_docs: bool) -> FastAPI:
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/" if enable_docs else None,
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: # pragma: no cover
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
- uvicorn.run(app, host=host, port=port, log_level="error")
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(__name__)
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
- 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)
@@ -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(__name__)
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
- return URL.create(drivername=driver, database=str(path))
85
-
86
- return URL.create(
87
- drivername=driver,
88
- username=username,
89
- password=password,
90
- host=host,
91
- port=port,
92
- database=database,
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, writeable: bool = False) -> APIRouter:
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 route for read operations against the table
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
- # Add route for write operations against the table
133
- if writeable:
134
- router.add_api_route(
135
- path="/",
136
- methods=["POST"],
137
- endpoint=create_post_record_handler(engine, table),
138
- status_code=status.HTTP_201_CREATED,
139
- summary="Create a new record.",
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.2"
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