auto-rest-api 0.1.0__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/models.py ADDED
@@ -0,0 +1,228 @@
1
+ """
2
+ The `models` module facilitates communication with relational databases
3
+ via dynamically generated object relational mappers (ORMs). Building
4
+ on the popular SQLAlchemy package, it natively supports multiple
5
+ Database Management Systems (DBMS) without requiring custom configuration
6
+ or setup.
7
+
8
+ !!! example "Example: Creating ORM objects"
9
+
10
+ Utility functions are provided for connecting to the database,
11
+ mapping the schema, and dynamically generating ORM models based on
12
+ the existing database structure.
13
+
14
+ ```python
15
+ connection_args = dict(...)
16
+ db_url = create_db_url(**connection_args)
17
+ db_conn = create_db_engine(db_url)
18
+ db_meta = create_db_metadata(db_conn)
19
+ db_models = create_db_models(db_meta)
20
+ ```
21
+
22
+ Support for asynchronous operations is automatically determined based on
23
+ the chosen database. If the driver supports asynchronous operations, the
24
+ connection and session handling are configured accordingly.
25
+
26
+ !!! important "Developer Note"
27
+
28
+ When working with database objects, the returned object type may vary
29
+ depending on whether the underlying driver is synchronous or asynchronous.
30
+ Of particular note are database engines (`Engine` / `AsyncEngine`) and
31
+ sessions (`Session` / `AsyncSession`).
32
+ """
33
+
34
+ import asyncio
35
+ import logging
36
+ from pathlib import Path
37
+ from typing import Callable
38
+
39
+ from pydantic.main import create_model, ModelT
40
+ from sqlalchemy import create_engine, Engine, MetaData, URL
41
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
42
+ from sqlalchemy.orm import declarative_base, Session
43
+
44
+ __all__ = [
45
+ "DBEngine",
46
+ "DBModel",
47
+ "DBSession",
48
+ "create_db_engine",
49
+ "create_db_interface",
50
+ "create_db_metadata",
51
+ "create_db_models",
52
+ "create_db_url",
53
+ "create_session_iterator",
54
+ ]
55
+
56
+ logger = logging.getLogger(__name__)
57
+
58
+ # Base classes and typing objects.
59
+ DBModel = declarative_base()
60
+ DBEngine = Engine | AsyncEngine
61
+ DBSession = Session | AsyncSession
62
+
63
+
64
+ def create_db_url(
65
+ driver: str,
66
+ database: str,
67
+ host: str | None = None,
68
+ port: int | None = None,
69
+ username: str | None = None,
70
+ password: str | None = None,
71
+ ) -> URL:
72
+ """Create a database URL from the provided parameters.
73
+
74
+ Args:
75
+ driver: The SQLAlchemy-compatible database driver.
76
+ database: The database name or file path (for SQLite).
77
+ host: The database server hostname or IP address.
78
+ port: The database server port number.
79
+ username: The username for authentication.
80
+ password: The password for the database user.
81
+
82
+ Returns:
83
+ A fully qualified database URL.
84
+ """
85
+
86
+ logger.debug("Resolving database URL.")
87
+
88
+ # Handle special case where SQLite uses file paths.
89
+ if "sqlite" in driver:
90
+ path = Path(database).resolve()
91
+ return URL.create(drivername=driver, database=str(path))
92
+
93
+ return URL.create(
94
+ drivername=driver,
95
+ username=username,
96
+ password=password,
97
+ host=host,
98
+ port=port,
99
+ database=database,
100
+ )
101
+
102
+
103
+ def create_db_engine(url: URL, **kwargs: dict[str: any]) -> DBEngine:
104
+ """Initialize a new database engine.
105
+
106
+ Instantiates and returns an `Engine` or `AsyncEngine` instance depending
107
+ on whether the database URL uses a driver with support for async operations.
108
+
109
+ Args:
110
+ url: A fully qualified database URL.
111
+ **kwargs: Keyword arguments passed to `create_engine`.
112
+
113
+ Returns:
114
+ A SQLAlchemy `Engine` or `AsyncEngine` instance.
115
+ """
116
+
117
+ logger.debug(f"Building database engine for {url}.")
118
+
119
+ if url.get_dialect().is_async:
120
+ engine = create_async_engine(url, **kwargs)
121
+ logger.debug("Asynchronous connection established.")
122
+ return engine
123
+
124
+ else:
125
+ engine = create_engine(url, **kwargs)
126
+ logger.debug("Synchronous connection established.")
127
+ return engine
128
+
129
+
130
+ async def _async_reflect_metadata(engine: AsyncEngine, metadata: MetaData) -> None:
131
+ """Helper function used to reflect database metadata using an async engine."""
132
+
133
+ async with engine.connect() as connection:
134
+ await connection.run_sync(metadata.reflect)
135
+
136
+
137
+ def create_db_metadata(engine: DBEngine) -> MetaData:
138
+ """Create and reflect metadata for the database connection.
139
+
140
+ Args:
141
+ engine: The database engine to use for reflection.
142
+
143
+ Returns:
144
+ A MetaData object reflecting the database schema.
145
+ """
146
+
147
+ logger.debug("Loading database metadata.")
148
+ metadata = MetaData()
149
+
150
+ if isinstance(engine, AsyncEngine):
151
+ asyncio.run(_async_reflect_metadata(engine, metadata))
152
+
153
+ else:
154
+ metadata.reflect(bind=engine)
155
+
156
+ return metadata
157
+
158
+
159
+ def create_db_models(metadata: MetaData) -> dict[str, DBModel]:
160
+ """Dynamically generate database models from a metadata instance.
161
+
162
+ Args:
163
+ metadata: A reflection of database metadata.
164
+
165
+ Returns:
166
+ A dictionary mapping table names to database models.
167
+ """
168
+
169
+ logger.debug("Building database models...")
170
+ models = {}
171
+
172
+ # Dynamically create a class for each table.
173
+ for table_name, table in metadata.tables.items():
174
+ logger.debug(f"> Creating model for table {table_name}.")
175
+ models[table_name] = type(
176
+ table_name.capitalize(),
177
+ (DBModel,),
178
+ {"__table__": table},
179
+ )
180
+
181
+ logger.debug(f"Successfully generated {len(models)} models.")
182
+ return models
183
+
184
+
185
+ def create_db_interface(model: DBModel) -> type[ModelT]:
186
+ """Create a Pydantic interface for a SQLAlchemy model.
187
+
188
+ Args:
189
+ model: A SQLAlchemy model to create an interface for.
190
+
191
+ Returns:
192
+ A Pydantic model class with the same structure as the provided SQLAlchemy model.
193
+ """
194
+
195
+ fields = {
196
+ col.name: (col.type.python_type, col.default if col.default is not None else ...)
197
+ for col in model.__table__.columns
198
+ }
199
+
200
+ return create_model(model.__name__, **fields)
201
+
202
+
203
+ def create_session_iterator(engine: DBEngine) -> Callable[[], DBSession]:
204
+ """Create a generator for database sessions.
205
+
206
+ Returns a synchronous or asynchronous function depending on whether
207
+ the database engine supports async operations. The type of session
208
+ returned also depends on the underlying database engine, and will
209
+ either be a `Session` or `AsyncSession` instance.
210
+
211
+ Args:
212
+ engine: Database engine to use when generating new sessions.
213
+
214
+ Returns:
215
+ A function that yields a single new database session.
216
+ """
217
+
218
+ if isinstance(engine, AsyncEngine):
219
+ async def session_iterator() -> AsyncSession:
220
+ async with AsyncSession(bind=engine, autocommit=False, autoflush=True) as session:
221
+ yield session
222
+
223
+ else:
224
+ def session_iterator() -> Session:
225
+ with Session(bind=engine, autocommit=False, autoflush=True) as session:
226
+ yield session
227
+
228
+ return session_iterator
auto_rest/params.py ADDED
@@ -0,0 +1,147 @@
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. A *get* function is used to parse
8
+ parameters from a FastAPI request and an *apply* function is used to apply
9
+ the arguments onto a SQLAlchemy query.
10
+
11
+ !!! example "Example: Parameter Parsing and Application"
12
+
13
+ When handling URL parameters, a *get* function is injected as a dependency
14
+ into the signature of the request handler. The parsed parameter dictionary
15
+ is then passed to an *apply* function.
16
+
17
+ ```python
18
+ from fastapi import FastAPI, Response, Depends
19
+ from sqlalchemy import select
20
+ from auto_rest.query_params import get_pagination_params, apply_pagination_params
21
+
22
+ app = FastAPI()
23
+
24
+ @app.get("/items/")
25
+ async def list_items(
26
+ pagination_params: dict = Depends(get_pagination_params),
27
+ response: Response
28
+ ):
29
+ query = select(SomeModel)
30
+ query = apply_pagination_params(query, pagination_params, response)
31
+ return ... # Logic to further process and execute the query goes here
32
+ ```
33
+ """
34
+
35
+ from typing import Literal
36
+
37
+ from fastapi import Query
38
+ from sqlalchemy import asc, desc
39
+ from sqlalchemy.sql.selectable import Select
40
+ from starlette.responses import Response
41
+
42
+ __all__ = [
43
+ "apply_ordering_params",
44
+ "apply_pagination_params",
45
+ "get_ordering_params",
46
+ "get_pagination_params",
47
+ ]
48
+
49
+
50
+ def get_pagination_params(
51
+ _limit_: int = Query(10, ge=0, description="The maximum number of records to return."),
52
+ _offset_: int = Query(0, ge=0, description="The starting index of the returned records."),
53
+ ) -> dict[str, int]:
54
+ """Extract pagination parameters from request query parameters.
55
+
56
+ Args:
57
+ _limit_: The maximum number of records to return.
58
+ _offset_: The starting index of the returned records.
59
+
60
+ Returns:
61
+ dict: A dictionary containing the `limit` and `offset` values.
62
+ """
63
+
64
+ return {"limit": _limit_, "offset": _offset_}
65
+
66
+
67
+ def apply_pagination_params(query: Select, params: dict[str, int], response: Response) -> Select:
68
+ """Apply pagination to a database query.
69
+
70
+ Returns a copy of the provided query with offset and limit parameters applied.
71
+ Compatible with parameters returned by the `get_pagination_params` method.
72
+
73
+ Args:
74
+ query: The database query to apply parameters to.
75
+ params: A dictionary containing parsed URL parameters.
76
+ response: The outgoing HTTP response object.
77
+
78
+ Returns:
79
+ A copy of the query modified to only return the paginated values.
80
+ """
81
+
82
+ limit = params.get("limit", 0)
83
+ offset = params.get("offset", 0)
84
+
85
+ if limit < 0 or offset < 0:
86
+ raise ValueError("Pagination parameters must be greater than or equal to zero.")
87
+
88
+ if limit == 0:
89
+ response.headers["X-Pagination-Applied"] = "false"
90
+ return query
91
+
92
+ response.headers["X-Pagination-Applied"] = "true"
93
+ response.headers["X-Pagination-Limit"] = str(limit)
94
+ response.headers["X-Pagination-Offset"] = str(offset)
95
+ return query.offset(offset).limit(limit)
96
+
97
+
98
+ def get_ordering_params(
99
+ _order_by_: str | None = Query(None, description="The field name to sort by."),
100
+ _direction_: Literal["asc", "desc"] = Query("asc", description="Sort results in 'asc' or 'desc' order.")
101
+ ) -> dict:
102
+ """Extract ordering parameters from request query parameters.
103
+
104
+ Args:
105
+ _order_by_: The field to order by.
106
+ _direction_: The direction to order by.
107
+
108
+ Returns:
109
+ dict: A dictionary containing the `order_by` and `direction` values.
110
+ """
111
+
112
+ return {"order_by": _order_by_, "direction": _direction_}
113
+
114
+
115
+ def apply_ordering_params(query: Select, params: dict, response: Response) -> Select:
116
+ """Apply ordering to a database query.
117
+
118
+ Returns a copy of the provided query with ordering parameters applied.
119
+ Compatible with parameters returned by the `get_ordering_params` method.
120
+
121
+ Args:
122
+ query: The database query to apply parameters to.
123
+ params: A dictionary containing parsed URL parameters.
124
+ response: The outgoing HTTP response object.
125
+
126
+ Returns:
127
+ A copy of the query modified to return ordered values.
128
+ """
129
+
130
+ order_by = params.get("order_by")
131
+ direction = params.get("direction", "asc")
132
+
133
+ if not order_by:
134
+ response.headers["X-Order-Applied"] = "false"
135
+ return query
136
+
137
+ response.headers["X-Order-Applied"] = "true"
138
+ response.headers["X-Order-By"] = order_by
139
+ response.headers["X-Order-Direction"] = direction
140
+
141
+ if direction == "asc":
142
+ return query.order_by(asc(order_by))
143
+
144
+ if direction == "desc":
145
+ return query.order_by(desc(order_by))
146
+
147
+ raise ValueError("Ordering direction must be 'asc' or 'desc'.")
auto_rest/queries.py ADDED
@@ -0,0 +1,107 @@
1
+ """
2
+ The `queries` module provides asynchronous wrapper functions around operations
3
+ involving SQLAlchemy sessions. These utilities automatically account for
4
+ variations in behavior between synchronous and asynchronous session types
5
+ (i.e., `Session` and `AsyncSession` instances). This ensures consistent query
6
+ handling and provides a streamlined interface for database interactions.
7
+
8
+ !!! example "Example: Query Execution"
9
+
10
+ Query utilities seamlessly support synchronous and asynchronous session types.
11
+
12
+ ```python
13
+ query = select(SomeModel).where(SomeModel.id == item_id)
14
+
15
+ with Session(...) as sync_session:
16
+ result = await execute_session_query(sync_session, query)
17
+
18
+ with AsyncSession(...) as async_session:
19
+ result = await execute_session_query(async_session, query)
20
+ ```
21
+ """
22
+
23
+ from fastapi import HTTPException
24
+ from sqlalchemy import Executable, Result
25
+ from sqlalchemy.ext.asyncio import AsyncSession
26
+ from starlette import status
27
+
28
+ from auto_rest.models import DBSession
29
+
30
+ __all__ = [
31
+ "commit_session",
32
+ "delete_session_record",
33
+ "execute_session_query",
34
+ "get_record_or_404"
35
+ ]
36
+
37
+
38
+ async def commit_session(session: DBSession) -> None:
39
+ """Commit a SQLAlchemy session.
40
+
41
+ Supports synchronous and asynchronous sessions.
42
+
43
+ Args:
44
+ session: The session to commit.
45
+ """
46
+
47
+ if isinstance(session, AsyncSession):
48
+ await session.commit()
49
+
50
+ else:
51
+ session.commit()
52
+
53
+
54
+ async def delete_session_record(session: DBSession, record: Result) -> None:
55
+ """Delete a record from the database using an existing session.
56
+
57
+ Does not automatically commit the session.
58
+ Supports synchronous and asynchronous sessions.
59
+
60
+ Args:
61
+ session: The session to use for deletion.
62
+ record: The record to be deleted.
63
+ """
64
+
65
+ if isinstance(session, AsyncSession):
66
+ await session.delete(record)
67
+
68
+ else:
69
+ session.delete(record)
70
+
71
+
72
+ async def execute_session_query(session: DBSession, query: Executable) -> Result:
73
+ """Execute a query in the given session and return the result.
74
+
75
+ Supports synchronous and asynchronous sessions.
76
+
77
+ Args:
78
+ session: The SQLAlchemy session to use for executing the query.
79
+ query: The query to be executed.
80
+
81
+ Returns:
82
+ The result of the executed query.
83
+ """
84
+
85
+ if isinstance(session, AsyncSession):
86
+ return await session.execute(query)
87
+
88
+ return session.execute(query)
89
+
90
+
91
+ def get_record_or_404(result: Result) -> any:
92
+ """Retrieve a scalar record from a query result or raise a 404 error.
93
+
94
+ Args:
95
+ result: The query result to extract the scalar record from.
96
+
97
+ Returns:
98
+ The scalar record if it exists.
99
+
100
+ Raises:
101
+ HTTPException: If the record is not found.
102
+ """
103
+
104
+ if not (record := result.scalar_one_or_none()):
105
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Record not found")
106
+
107
+ return record
auto_rest/routers.py ADDED
@@ -0,0 +1,168 @@
1
+ """
2
+ API routers are responsible for redirecting incoming HTTP requests to the
3
+ appropriate handling logic. Router objects are created using a factory
4
+ pattern, with each router being responsible for a single application
5
+ resource. Each factory returns an `APIRouter` instance preconfigured
6
+ with request handling logic for the relevant resource. This allows
7
+ routers to be added directly to an API application instance.
8
+
9
+ !!! example "Example: Creating and Adding a Router"
10
+
11
+ Care should be taken to avoid path conflicts when adding routers
12
+ to an API application instance. Using a unique `prefix` value
13
+ ensures that each router's endpoints are properly namespaced and
14
+ unique.
15
+
16
+ ```python
17
+ from fastapi import FastAPI
18
+ from auto_rest.routers import create_welcome_router
19
+
20
+ app = FastAPI()
21
+ welcome_router = create_welcome_router()
22
+ app.include_router(welcome_router, prefix="/welcome")
23
+ ```
24
+ """
25
+
26
+ from fastapi import APIRouter
27
+ from sqlalchemy import MetaData
28
+ from starlette import status
29
+
30
+ from auto_rest.handlers import *
31
+ from auto_rest.models import DBEngine, DBModel
32
+
33
+ __all__ = [
34
+ "create_meta_router",
35
+ "create_model_router",
36
+ "create_welcome_router",
37
+ ]
38
+
39
+
40
+ def create_welcome_router() -> APIRouter:
41
+ """Create an API router for returning a welcome message.
42
+
43
+ Returns:
44
+ An `APIRouter` with a single route for retrieving a welcome message.
45
+ """
46
+
47
+ router = APIRouter()
48
+ router.add_api_route("/", create_welcome_handler(), methods=["GET"], include_in_schema=False)
49
+ return router
50
+
51
+
52
+ def create_meta_router(engine: DBEngine, metadata: MetaData, name: str, version: str) -> APIRouter:
53
+ """Create an API router for returning database metadata.
54
+
55
+ Includes routes for retrieving the database driver, database schema,
56
+ and application/schema version.
57
+
58
+ Args:
59
+ engine: The database engine used to facilitate database interactions.
60
+ metadata: The metadata object containing the database schema.
61
+ version: The application name.
62
+ version: The application versionnumber.
63
+
64
+ Returns:
65
+ An `APIRouter` with a routes for retrieving application metadata.
66
+ """
67
+
68
+ router = APIRouter()
69
+ tags = ["Application Metadata"]
70
+
71
+ router.add_api_route("/app", create_about_handler(name, version), methods=["GET"], tags=tags)
72
+ router.add_api_route("/engine", create_engine_handler(engine), methods=["GET"], tags=tags)
73
+ router.add_api_route("/schema", create_schema_handler(metadata), methods=["GET"], tags=tags)
74
+ return router
75
+
76
+
77
+ def create_model_router(engine: DBEngine, model: DBModel, writeable: bool = False) -> APIRouter:
78
+ """Create an API router with endpoint handlers for the given database model.
79
+
80
+ Args:
81
+ engine: The SQLAlchemy engine connected to the database.
82
+ model: The ORM model class representing a database table.
83
+ writeable: Whether the router should include support for write operations.
84
+
85
+ Returns:
86
+ An APIRouter instance with routes for database operations on the model.
87
+ """
88
+
89
+ router = APIRouter()
90
+
91
+ # Construct path parameters from primary key columns
92
+ pk_columns = model.__table__.primary_key.columns
93
+ path_params_url = "/".join(f"{{{col.name}}}" for col in pk_columns)
94
+ path_params_openapi = {
95
+ "parameters": [
96
+ {
97
+ "name": col.name,
98
+ "in": "path",
99
+ "required": True
100
+ } for col in pk_columns
101
+ ]
102
+ }
103
+
104
+ # Raise an error if no primary key columns are found
105
+ # (SQLAlchemy should ensure this never happens)
106
+ if not pk_columns: # pragma: no cover
107
+ raise RuntimeError(f"No primary key columns found for table {model.__tablename__}.")
108
+
109
+ # Define routes for read operations
110
+ router.add_api_route(
111
+ path="/",
112
+ methods=["GET"],
113
+ endpoint=create_list_records_handler(engine, model),
114
+ status_code=status.HTTP_200_OK,
115
+ tags=[model.__name__],
116
+ openapi_extra=path_params_openapi
117
+ )
118
+
119
+ router.add_api_route(
120
+ path=f"/{path_params_url}/",
121
+ methods=["GET"],
122
+ endpoint=create_get_record_handler(engine, model),
123
+ status_code=status.HTTP_200_OK,
124
+ tags=[model.__name__],
125
+ openapi_extra=path_params_openapi
126
+ )
127
+
128
+ if not writeable:
129
+ return router
130
+
131
+ # Define routes for write operations
132
+ router.add_api_route(
133
+ path="/",
134
+ methods=["POST"],
135
+ endpoint=create_post_record_handler(engine, model),
136
+ status_code=status.HTTP_201_CREATED,
137
+ tags=[model.__name__],
138
+ openapi_extra=path_params_openapi
139
+ )
140
+
141
+ router.add_api_route(
142
+ path=f"/{path_params_url}/",
143
+ methods=["PUT"],
144
+ endpoint=create_put_record_handler(engine, model),
145
+ status_code=status.HTTP_200_OK,
146
+ tags=[model.__name__],
147
+ openapi_extra=path_params_openapi
148
+ )
149
+
150
+ router.add_api_route(
151
+ path=f"/{path_params_url}/",
152
+ methods=["PATCH"],
153
+ endpoint=create_patch_record_handler(engine, model),
154
+ status_code=status.HTTP_200_OK,
155
+ tags=[model.__name__],
156
+ openapi_extra=path_params_openapi
157
+ )
158
+
159
+ router.add_api_route(
160
+ path=f"/{path_params_url}/",
161
+ methods=["DELETE"],
162
+ endpoint=create_delete_record_handler(engine, model),
163
+ status_code=status.HTTP_200_OK,
164
+ tags=[model.__name__],
165
+ openapi_extra=path_params_openapi
166
+ )
167
+
168
+ return router