auto-rest-api 0.1.4__tar.gz → 0.1.6__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.
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: auto-rest-api
3
- Version: 0.1.4
3
+ Version: 0.1.6
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)
@@ -9,7 +9,7 @@ from .routers import *
9
9
 
10
10
  __all__ = ["main", "run_application"]
11
11
 
12
- logger = logging.getLogger("auto-rest")
12
+ logger = logging.getLogger("auto_rest")
13
13
 
14
14
 
15
15
  def main() -> None: # pragma: no cover
@@ -15,19 +15,21 @@ deploying Fast-API applications.
15
15
  """
16
16
 
17
17
  import logging
18
+ from http import HTTPStatus
18
19
 
19
20
  import uvicorn
21
+ from asgi_correlation_id import CorrelationIdMiddleware
20
22
  from fastapi import FastAPI, Request
21
23
  from fastapi.middleware.cors import CORSMiddleware
22
24
  from starlette.responses import Response
23
25
 
24
26
  __all__ = ["create_app", "run_server"]
25
27
 
26
- logger = logging.getLogger("auto-rest")
28
+ logger = logging.getLogger("auto_rest.access")
27
29
 
28
30
 
29
31
  async def logging_middleware(request: Request, call_next: callable) -> Response:
30
- """FastAPI middleware for the logging response status codes.
32
+ """FastAPI middleware for logging response status codes.
31
33
 
32
34
  Args:
33
35
  request: The incoming HTTP request.
@@ -37,9 +39,30 @@ async def logging_middleware(request: Request, call_next: callable) -> Response:
37
39
  The outgoing HTTP response.
38
40
  """
39
41
 
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}")
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": request.url.path,
48
+ }
49
+
50
+ if request.url.query:
51
+ request_meta["endpoint"] += "?" + request.url.query
52
+
53
+ # Execute handling logic
54
+ try:
55
+ response = await call_next(request)
56
+
57
+ except Exception as exc:
58
+ logger.error(str(exec), exc_info=exc, extra=request_meta)
59
+ raise
60
+
61
+ # Log the outgoing response
62
+ status = HTTPStatus(response.status_code)
63
+ level = logging.INFO if status < 400 else logging.ERROR
64
+ logger.log(level, f"{status} {status.phrase}", extra=request_meta)
65
+
43
66
  return response
44
67
 
45
68
 
@@ -66,6 +89,7 @@ def create_app(app_title: str, app_version: str) -> FastAPI:
66
89
  )
67
90
 
68
91
  app.middleware("http")(logging_middleware)
92
+ app.add_middleware(CorrelationIdMiddleware)
69
93
  app.add_middleware(
70
94
  CORSMiddleware,
71
95
  allow_credentials=True,
@@ -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
- from uvicorn.logging import DefaultFormatter
39
-
40
- __all__ = ["VERSION", "configure_cli_logging", "create_cli_parser"]
38
+ __all__ = ["configure_cli_logging", "create_cli_parser", "VERSION"]
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,17 +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
- # Set up logging with a stream handler.
61
- handler = logging.StreamHandler()
62
- handler.setFormatter(DefaultFormatter(fmt="%(levelprefix)s %(message)s"))
63
- logging.basicConfig(
64
- force=True,
65
- format="%(levelprefix)s %(message)s",
66
- handlers=[handler],
67
- )
68
-
69
- logging.getLogger("auto-rest").setLevel(level)
70
- logging.getLogger("sqlalchemy").setLevel(1000)
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
+ })
71
109
 
72
110
 
73
111
  def create_cli_parser(exit_on_error: bool = True) -> ArgumentParser:
@@ -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("auto-rest")
79
-
80
77
 
81
78
  def create_welcome_handler() -> Callable[[], Awaitable[PydanticModel]]:
82
79
  """Create an endpoint handler that returns an application welcome message.
@@ -192,32 +189,54 @@ def create_list_records_handler(engine: DBEngine, table: Table) -> Callable[...,
192
189
 
193
190
  interface = create_interface(table)
194
191
  interface_opt = create_interface(table, mode="optional")
195
- columns = tuple(table.columns.keys())
192
+ col_names = tuple(table.columns.keys())
196
193
 
197
194
  async def list_records_handler(
198
195
  response: Response,
199
196
  session: DBSession = Depends(create_session_iterator(engine)),
197
+ filters: interface_opt = Depends(),
200
198
  _limit_: int = Query(0, ge=0, description="The maximum number of records to return."),
201
199
  _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.")
200
+ _order_by_: Optional[Literal[*col_names]] = Query(None, description="The field name to sort by."),
201
+ _direction_: Literal["asc", "desc"] = Query("asc", description="Sort results in 'asc' or 'desc' order."),
204
202
  ) -> list[interface]:
205
203
  """Fetch a list of records from the database.
206
204
 
207
205
  URL query parameters are used to enable filtering, ordering, and paginating returned values.
208
206
  """
209
207
 
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
-
215
208
  query = select(table)
216
- query = apply_pagination_params(query, _limit_, _offset_)
217
- query = apply_ordering_params(query, _order_by_, _direction_)
218
209
 
219
- result = await execute_session_query(session, query)
220
- return [row._mapping for row in result.all()]
210
+ # Fetch data per the request parameters
211
+ for param, value in filters:
212
+ if value is not None:
213
+ column = getattr(table.c, param)
214
+ if value.lower() == "_null_":
215
+ query = query.filter(column.is_(None))
216
+
217
+ else:
218
+ query = query.filter(column.ilike(f"%{value}%"))
219
+
220
+ if _limit_ > 0:
221
+ query = query.offset(_offset_).limit(_limit_)
222
+
223
+ if _order_by_ is not None:
224
+ direction = {'desc': desc, 'asc': asc}[_direction_]
225
+ query = query.order_by(direction(_order_by_))
226
+
227
+ # Determine total record count
228
+ total_count_query = select(func.count()).select_from(table)
229
+ total_count = await execute_session_query(session, total_count_query)
230
+
231
+ # Set headers
232
+ response.headers["x-pagination-limit"] = str(_limit_)
233
+ response.headers["x-pagination-offset"] = str(_offset_)
234
+ response.headers["x-pagination-total"] = str(total_count.first()[0])
235
+ response.headers["x-order-by"] = str(_order_by_)
236
+ response.headers["x-order-direction"] = str(_direction_)
237
+
238
+ # noinspection PyTypeChecker
239
+ return await execute_session_query(session, query)
221
240
 
222
241
  return list_records_handler
223
242
 
@@ -250,7 +269,7 @@ def create_get_record_handler(engine: DBEngine, table: Table) -> Callable[..., A
250
269
  return get_record_handler
251
270
 
252
271
 
253
- def create_post_record_handler(engine: DBEngine, table: Table) -> Callable[..., Awaitable[PydanticModel]]:
272
+ def create_post_record_handler(engine: DBEngine, table: Table) -> Callable[..., Awaitable[None]]:
254
273
  """Create a function for handling POST requests against a record in the database.
255
274
 
256
275
  Args:
@@ -269,7 +288,7 @@ def create_post_record_handler(engine: DBEngine, table: Table) -> Callable[...,
269
288
  ) -> None:
270
289
  """Create a new record in the database."""
271
290
 
272
- query = insert(table).values(**data.dict())
291
+ query = insert(table).values(**data.model_dump())
273
292
  await execute_session_query(session, query)
274
293
  await commit_session(session)
275
294
 
@@ -32,7 +32,7 @@ connection and session handling are configured accordingly.
32
32
  import asyncio
33
33
  import logging
34
34
  from pathlib import Path
35
- from typing import Callable
35
+ from typing import AsyncGenerator, Callable, Generator
36
36
 
37
37
  import yaml
38
38
  from sqlalchemy import create_engine, Engine, MetaData, URL
@@ -49,7 +49,7 @@ __all__ = [
49
49
  "parse_db_settings"
50
50
  ]
51
51
 
52
- logger = logging.getLogger("auto-rest")
52
+ logger = logging.getLogger("auto_rest")
53
53
 
54
54
  # Base classes and typing objects.
55
55
  DBEngine = Engine | AsyncEngine
@@ -70,7 +70,7 @@ def parse_db_settings(path: Path | None) -> dict[str, any]:
70
70
  logger.debug(f"Parsing engine configuration from {path}.")
71
71
  return yaml.safe_load(path.read_text()) or dict()
72
72
 
73
- logger.debug("No connection file specified.")
73
+ logger.debug("No configuration file specified.")
74
74
  return {}
75
75
 
76
76
 
@@ -129,8 +129,6 @@ def create_db_engine(url: URL, **kwargs: dict[str: any]) -> DBEngine:
129
129
  A SQLAlchemy `Engine` or `AsyncEngine` instance.
130
130
  """
131
131
 
132
- logger.debug(f"Building database engine for {url}.")
133
-
134
132
  if url.get_dialect().is_async:
135
133
  engine = create_async_engine(url, **kwargs)
136
134
  logger.debug("Asynchronous connection established.")
@@ -171,7 +169,7 @@ def create_db_metadata(engine: DBEngine) -> MetaData:
171
169
  return metadata
172
170
 
173
171
 
174
- def create_session_iterator(engine: DBEngine) -> Callable[[], DBSession]:
172
+ def create_session_iterator(engine: DBEngine) -> Callable[[], Generator[Session, None, None] | AsyncGenerator[AsyncSession, None]]:
175
173
  """Create a generator for database sessions.
176
174
 
177
175
  Returns a synchronous or asynchronous function depending on whether
@@ -187,12 +185,12 @@ def create_session_iterator(engine: DBEngine) -> Callable[[], DBSession]:
187
185
  """
188
186
 
189
187
  if isinstance(engine, AsyncEngine):
190
- async def session_iterator() -> AsyncSession:
188
+ async def session_iterator() -> AsyncGenerator[AsyncSession, None]:
191
189
  async with AsyncSession(bind=engine, autocommit=False, autoflush=True) as session:
192
190
  yield session
193
191
 
194
192
  else:
195
- def session_iterator() -> Session:
193
+ def session_iterator() -> Generator[Session, None, None]:
196
194
  with Session(bind=engine, autocommit=False, autoflush=True) as session:
197
195
  yield session
198
196
 
@@ -20,81 +20,23 @@ handling and provides a streamlined interface for database interactions.
20
20
  ```
21
21
  """
22
22
 
23
- from typing import Literal
23
+ import logging
24
24
 
25
25
  from fastapi import HTTPException
26
- from sqlalchemy import asc, desc, Executable, Result, Select
26
+ from sqlalchemy import Executable, Result
27
27
  from sqlalchemy.ext.asyncio import AsyncSession
28
28
  from starlette import status
29
29
 
30
30
  from auto_rest.models import DBSession
31
31
 
32
32
  __all__ = [
33
- "apply_ordering_params",
34
- "apply_pagination_params",
35
33
  "commit_session",
36
34
  "delete_session_record",
37
35
  "execute_session_query",
38
36
  "get_record_or_404"
39
37
  ]
40
38
 
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)
39
+ logger = logging.getLogger("auto_rest.query")
98
40
 
99
41
 
100
42
  async def commit_session(session: DBSession) -> None:
@@ -124,6 +66,7 @@ async def delete_session_record(session: DBSession, record: Result) -> None:
124
66
  record: The record to be deleted.
125
67
  """
126
68
 
69
+ logger.debug("Deleting record.")
127
70
  if isinstance(session, AsyncSession):
128
71
  await session.delete(record)
129
72
 
@@ -144,6 +87,7 @@ async def execute_session_query(session: DBSession, query: Executable) -> Result
144
87
  The result of the executed query.
145
88
  """
146
89
 
90
+ logger.debug(str(query).replace("\n", " "))
147
91
  if isinstance(session, AsyncSession):
148
92
  return await session.execute(query)
149
93
 
@@ -38,7 +38,7 @@ __all__ = [
38
38
  "create_welcome_router",
39
39
  ]
40
40
 
41
- logger = logging.getLogger("auto-rest")
41
+ logger = logging.getLogger("auto_rest")
42
42
 
43
43
 
44
44
  def create_welcome_router() -> APIRouter:
@@ -83,7 +83,7 @@ def create_meta_router(engine: DBEngine, metadata: MetaData, name: str, version:
83
83
  tags = ["Application Metadata"]
84
84
 
85
85
  router.add_api_route(
86
- path="/app",
86
+ path="/app/",
87
87
  methods=["GET"],
88
88
  endpoint=create_about_handler(name, version),
89
89
  summary="Fetch application metadata.",
@@ -91,7 +91,7 @@ def create_meta_router(engine: DBEngine, metadata: MetaData, name: str, version:
91
91
  )
92
92
 
93
93
  router.add_api_route(
94
- path="/engine",
94
+ path="/engine/",
95
95
  methods=["GET"],
96
96
  endpoint=create_engine_handler(engine),
97
97
  summary="Fetch database metadata.",
@@ -99,7 +99,7 @@ def create_meta_router(engine: DBEngine, metadata: MetaData, name: str, version:
99
99
  )
100
100
 
101
101
  router.add_api_route(
102
- path="/schema",
102
+ path="/schema/",
103
103
  methods=["GET"],
104
104
  endpoint=create_schema_handler(metadata),
105
105
  summary="Fetch the database schema.",
@@ -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.4"
7
+ version = "0.1.6"
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" }]
@@ -24,20 +24,22 @@ classifiers = [
24
24
  "Topic :: Software Development",
25
25
  "Typing :: Typed"
26
26
  ]
27
- requires-python = ">=3.11"
27
+ requires-python = ">=3.11,<4"
28
28
  dependencies = [
29
29
  "aiomysql~=0.2",
30
30
  "aioodbc~=0.5",
31
31
  "aiosqlite~=0.20",
32
+ "asgi-correlation-id (>=4.3.4,<5.0.0)",
32
33
  "asyncpg~=0.30",
34
+ "colorlog (>=6.9.0,<7.0.0)",
33
35
  "fastapi~=0.115",
34
36
  "greenlet~=3.1",
35
37
  "httpx~=0.28",
36
38
  "oracledb~=2.5",
37
39
  "pydantic~=2.10",
40
+ "pyyaml (>=6.0.2,<7.0.0)",
38
41
  "sqlalchemy~=2.0",
39
42
  "uvicorn~=0.34",
40
- "pyyaml (>=6.0.2,<7.0.0)",
41
43
  ]
42
44
 
43
45
  [tool.poetry]
File without changes
File without changes