auto-rest-api 0.1.4__tar.gz → 0.1.5__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,11 +1,11 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: auto-rest-api
3
- Version: 0.1.4
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)
@@ -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,27 @@ 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": 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)
62
+
43
63
  return response
44
64
 
45
65
 
@@ -66,6 +86,7 @@ def create_app(app_title: str, app_version: str) -> FastAPI:
66
86
  )
67
87
 
68
88
  app.middleware("http")(logging_middleware)
89
+ app.add_middleware(CorrelationIdMiddleware)
69
90
  app.add_middleware(
70
91
  CORSMiddleware,
71
92
  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"]
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.
@@ -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
- 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_)
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
- query = apply_pagination_params(query, _limit_, _offset_)
217
- query = apply_ordering_params(query, _order_by_, _direction_)
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()]
@@ -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.")
@@ -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.5"
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