auto-rest-api 0.1.1__py3-none-any.whl → 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of auto-rest-api might be problematic. Click here for more details.

auto_rest/__main__.py CHANGED
@@ -69,13 +69,15 @@ def run_application(
69
69
  app_version: version number for the generated OpenAPI schema.
70
70
  """
71
71
 
72
- # Connect to and map the database.
73
72
  logger.info(f"Mapping database schema for {db_name}.")
73
+
74
+ # Resolve database connection settings
74
75
  db_url = create_db_url(driver=db_driver, host=db_host, port=db_port, database=db_name, username=db_user, password=db_pass)
75
76
  db_kwargs = yaml.safe_load(db_config.read_text()) if db_config else {}
77
+
78
+ # Connect to and map the database.
76
79
  db_conn = create_db_engine(db_url, **db_kwargs)
77
80
  db_meta = create_db_metadata(db_conn)
78
- db_models = create_db_models(db_meta)
79
81
 
80
82
  # Build an empty application and dynamically add the requested functionality.
81
83
  logger.info("Creating API application.")
@@ -83,9 +85,9 @@ def run_application(
83
85
  app.include_router(create_welcome_router(), prefix="")
84
86
  app.include_router(create_meta_router(db_conn, db_meta, app_title, app_version), prefix="/meta")
85
87
 
86
- for model_name, model in db_models.items():
87
- logger.info(f"Adding `/db/{model_name}` endpoint.")
88
- app.include_router(create_model_router(db_conn, model, enable_write), prefix=f"/db/{model_name}")
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}")
89
91
 
90
92
  # Launch the API server.
91
93
  logger.info(f"Launching API server on http://{server_host}:{server_port}.")
auto_rest/handlers.py CHANGED
@@ -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
- from sqlalchemy import insert, MetaData, select
60
- from starlette.requests import Request
59
+ from sqlalchemy import insert, MetaData, select, Table
61
60
 
61
+ from .interfaces import *
62
62
  from .models import *
63
- from .params import *
64
63
  from .queries import *
65
64
 
66
65
  __all__ = [
@@ -180,120 +179,130 @@ def create_schema_handler(metadata: MetaData) -> Callable[[], Awaitable[Pydantic
180
179
  return schema_handler
181
180
 
182
181
 
183
- def create_list_records_handler(engine: DBEngine, model: DBModel) -> Callable[..., Awaitable[list[PydanticModel]]]:
182
+ def create_list_records_handler(engine: DBEngine, table: Table) -> Callable[..., Awaitable[list[PydanticModel]]]:
184
183
  """Create an endpoint handler that returns a list of records from a database table.
185
184
 
186
185
  Args:
187
186
  engine: Database engine to use when executing queries.
188
- model: The ORM object to use for database manipulations.
187
+ table: The database table to query against.
189
188
 
190
189
  Returns:
191
190
  An async function that returns a list of records from the given database model.
192
191
  """
193
192
 
194
- interface = create_db_interface(model)
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(model),
200
- ordering_params: dict[str, int] = create_ordering_dependency(model),
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
 
207
- query = select(model)
208
- query = apply_pagination_params(query, pagination_params, response)
209
- query = apply_ordering_params(query, ordering_params, response)
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
+ query = select(table)
216
+ query = apply_pagination_params(query, _limit_, _offset_)
217
+ query = apply_ordering_params(query, _order_by_, _direction_)
218
+
210
219
  result = await execute_session_query(session, query)
211
- return [interface.model_validate(record.__dict__) for record in result.scalars().all()]
220
+ return [row._mapping for row in result.all()]
212
221
 
213
222
  return list_records_handler
214
223
 
215
224
 
216
- def create_get_record_handler(engine: DBEngine, model: DBModel) -> Callable[..., Awaitable[PydanticModel]]:
225
+ def create_get_record_handler(engine: DBEngine, table: Table) -> Callable[..., Awaitable[PydanticModel]]:
217
226
  """Create a function for handling GET requests against a single record in the database.
218
227
 
219
228
  Args:
220
229
  engine: Database engine to use when executing queries.
221
- model: The ORM object to use for database manipulations.
230
+ table: The database table to query against.
222
231
 
223
232
  Returns:
224
- An async function that returns a single record from the given database model.
233
+ An async function that returns a single record from the given database table.
225
234
  """
226
235
 
227
- interface = create_db_interface(model)
236
+ interface = create_interface(table)
237
+ pk_interface = create_interface(table, pk_only=True, mode='required')
228
238
 
229
239
  async def get_record_handler(
230
- request: Request,
240
+ pk: pk_interface = Depends(),
231
241
  session: DBSession = Depends(create_session_iterator(engine)),
232
242
  ) -> interface:
233
243
  """Fetch a single record from the database."""
234
244
 
235
- query = select(model).filter_by(**request.path_params)
245
+ query = select(table).filter_by(**pk.model_dump())
236
246
  result = await execute_session_query(session, query)
237
247
  record = get_record_or_404(result)
238
- return interface.model_validate(record.__dict__)
248
+ return record
239
249
 
240
250
  return get_record_handler
241
251
 
242
252
 
243
- def create_post_record_handler(engine: DBEngine, model: DBModel) -> Callable[..., Awaitable[PydanticModel]]:
253
+ def create_post_record_handler(engine: DBEngine, table: Table) -> Callable[..., Awaitable[PydanticModel]]:
244
254
  """Create a function for handling POST requests against a record in the database.
245
255
 
246
256
  Args:
247
257
  engine: Database engine to use when executing queries.
248
- model: The ORM object to use for database manipulations.
258
+ table: The database table to query against.
249
259
 
250
260
  Returns:
251
261
  An async function that handles record creation.
252
262
  """
253
263
 
254
- interface = create_db_interface(model)
264
+ interface = create_interface(table)
255
265
 
256
266
  async def post_record_handler(
257
267
  data: interface,
258
268
  session: DBSession = Depends(create_session_iterator(engine)),
259
- ) -> interface:
269
+ ) -> None:
260
270
  """Create a new record in the database."""
261
271
 
262
- query = insert(model).values(**data.dict())
263
- result = await execute_session_query(session, query)
264
- record = get_record_or_404(result)
265
-
272
+ query = insert(table).values(**data.dict())
273
+ await execute_session_query(session, query)
266
274
  await commit_session(session)
267
- return interface.model_validate(record.__dict__)
268
275
 
269
276
  return post_record_handler
270
277
 
271
278
 
272
- def create_put_record_handler(engine: DBEngine, model: DBModel) -> Callable[..., Awaitable[PydanticModel]]:
279
+ def create_put_record_handler(engine: DBEngine, table: Table) -> Callable[..., Awaitable[PydanticModel]]:
273
280
  """Create a function for handling PUT requests against a record in the database.
274
281
 
275
282
  Args:
276
283
  engine: Database engine to use when executing queries.
277
- model: The ORM object to use for database manipulations.
284
+ table: The database table to query against.
278
285
 
279
286
  Returns:
280
287
  An async function that handles record updates.
281
288
  """
282
289
 
283
- interface = create_db_interface(model)
290
+ interface = create_interface(table)
291
+ opt_interface = create_interface(table, mode='optional')
292
+ pk_interface = create_interface(table, pk_only=True, mode='required')
284
293
 
285
294
  async def put_record_handler(
286
- request: Request,
287
- data: interface,
295
+ data: opt_interface,
296
+ pk: pk_interface = Depends(),
288
297
  session: DBSession = Depends(create_session_iterator(engine)),
289
298
  ) -> interface:
290
299
  """Replace record values in the database with the provided data."""
291
300
 
292
- query = select(model).filter_by(**request.path_params)
301
+ query = select(table).filter_by(**pk.model_dump())
293
302
  result = await execute_session_query(session, query)
294
303
  record = get_record_or_404(result)
295
304
 
296
- for key, value in data.dict().items():
305
+ for key, value in data.model_dump().items():
297
306
  setattr(record, key, value)
298
307
 
299
308
  await commit_session(session)
@@ -302,57 +311,60 @@ def create_put_record_handler(engine: DBEngine, model: DBModel) -> Callable[...,
302
311
  return put_record_handler
303
312
 
304
313
 
305
- def create_patch_record_handler(engine: DBEngine, model: DBModel) -> Callable[..., Awaitable[PydanticModel]]:
314
+ def create_patch_record_handler(engine: DBEngine, table: Table) -> Callable[..., Awaitable[PydanticModel]]:
306
315
  """Create a function for handling PATCH requests against a record in the database.
307
316
 
308
317
  Args:
309
318
  engine: Database engine to use when executing queries.
310
- model: The ORM object to use for database manipulations.
319
+ table: The database table to query against.
311
320
 
312
321
  Returns:
313
322
  An async function that handles record updates.
314
323
  """
315
324
 
316
- interface = create_db_interface(model)
325
+ interface = create_interface(table)
326
+ pk_interface = create_interface(table, pk_only=True, mode='required')
317
327
 
318
328
  async def patch_record_handler(
319
- request: Request,
320
329
  data: interface,
330
+ pk: pk_interface = Depends(),
321
331
  session: DBSession = Depends(create_session_iterator(engine)),
322
332
  ) -> interface:
323
333
  """Update record values in the database with the provided data."""
324
334
 
325
- query = select(model).filter_by(**request.path_params)
335
+ query = select(table).filter_by(**pk.model_dump())
326
336
  result = await execute_session_query(session, query)
327
337
  record = get_record_or_404(result)
328
338
 
329
- for key, value in data.dict(exclude_unset=True).items():
339
+ for key, value in data.model_dump(exclude_unset=True).items():
330
340
  setattr(record, key, value)
331
341
 
332
342
  await commit_session(session)
333
- return interface(record.__dict__)
343
+ return record
334
344
 
335
345
  return patch_record_handler
336
346
 
337
347
 
338
- def create_delete_record_handler(engine: DBEngine, model: DBModel) -> Callable[..., Awaitable[None]]:
348
+ def create_delete_record_handler(engine: DBEngine, table: Table) -> Callable[..., Awaitable[None]]:
339
349
  """Create a function for handling DELETE requests against a record in the database.
340
350
 
341
351
  Args:
342
352
  engine: Database engine to use when executing queries.
343
- model: The ORM object to use for database manipulations.
353
+ table: The database table to query against.
344
354
 
345
355
  Returns:
346
356
  An async function that handles record deletion.
347
357
  """
348
358
 
359
+ pk_interface = create_interface(table, pk_only=True, mode='required')
360
+
349
361
  async def delete_record_handler(
350
- request: Request,
362
+ pk: pk_interface = Depends(),
351
363
  session: DBSession = Depends(create_session_iterator(engine)),
352
364
  ) -> None:
353
365
  """Delete a record from the database."""
354
366
 
355
- query = select(model).filter_by(**request.path_params)
367
+ query = select(table).filter_by(**pk.model_dump())
356
368
  result = await execute_session_query(session, query)
357
369
  record = get_record_or_404(result)
358
370
 
@@ -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)
auto_rest/models.py CHANGED
@@ -5,18 +5,16 @@ on the popular SQLAlchemy package, it natively supports multiple
5
5
  Database Management Systems (DBMS) without requiring custom configuration
6
6
  or setup.
7
7
 
8
- !!! example "Example: Creating ORM objects"
8
+ !!! example "Example: Mapping Database Metadata"
9
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.
10
+ Utility functions are provided for connecting to the database
11
+ and mapping the underlying schema.
13
12
 
14
13
  ```python
15
14
  connection_args = dict(...)
16
15
  db_url = create_db_url(**connection_args)
17
16
  db_conn = create_db_engine(db_url)
18
17
  db_meta = create_db_metadata(db_conn)
19
- db_models = create_db_models(db_meta)
20
18
  ```
21
19
 
22
20
  Support for asynchronous operations is automatically determined based on
@@ -36,29 +34,22 @@ import logging
36
34
  from pathlib import Path
37
35
  from typing import Callable
38
36
 
39
- from pydantic.main import create_model, BaseModel as PydanticModel
40
37
  from sqlalchemy import create_engine, Engine, MetaData, URL
41
38
  from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
42
- from sqlalchemy.orm import declarative_base, Session
39
+ from sqlalchemy.orm import Session
43
40
 
44
41
  __all__ = [
45
42
  "DBEngine",
46
- "DBModel",
47
43
  "DBSession",
48
44
  "create_db_engine",
49
- "create_db_interface",
50
45
  "create_db_metadata",
51
- "create_db_models",
52
46
  "create_db_url",
53
47
  "create_session_iterator",
54
48
  ]
55
49
 
56
50
  logger = logging.getLogger(__name__)
57
51
 
58
- Base = declarative_base()
59
-
60
52
  # Base classes and typing objects.
61
- DBModel = type[Base]
62
53
  DBEngine = Engine | AsyncEngine
63
54
  DBSession = Session | AsyncSession
64
55
 
@@ -133,7 +124,7 @@ async def _async_reflect_metadata(engine: AsyncEngine, metadata: MetaData) -> No
133
124
  """Helper function used to reflect database metadata using an async engine."""
134
125
 
135
126
  async with engine.connect() as connection:
136
- await connection.run_sync(metadata.reflect)
127
+ await connection.run_sync(metadata.reflect, views=True)
137
128
 
138
129
 
139
130
  def create_db_metadata(engine: DBEngine) -> MetaData:
@@ -153,55 +144,11 @@ def create_db_metadata(engine: DBEngine) -> MetaData:
153
144
  asyncio.run(_async_reflect_metadata(engine, metadata))
154
145
 
155
146
  else:
156
- metadata.reflect(bind=engine)
147
+ metadata.reflect(bind=engine, views=True)
157
148
 
158
149
  return metadata
159
150
 
160
151
 
161
- def create_db_models(metadata: MetaData) -> dict[str, DBModel]:
162
- """Dynamically generate database models from a metadata instance.
163
-
164
- Args:
165
- metadata: A reflection of database metadata.
166
-
167
- Returns:
168
- A dictionary mapping table names to database models.
169
- """
170
-
171
- logger.debug("Building database models...")
172
- models = {}
173
-
174
- # Dynamically create a class for each table.
175
- for table_name, table in metadata.tables.items():
176
- logger.debug(f"> Creating model for table {table_name}.")
177
- models[table_name] = type(
178
- table_name.capitalize(),
179
- (Base,),
180
- {"__table__": table},
181
- )
182
-
183
- logger.debug(f"Successfully generated {len(models)} models.")
184
- return models
185
-
186
-
187
- def create_db_interface(model: DBModel) -> type[PydanticModel]:
188
- """Create a Pydantic interface for a SQLAlchemy model.
189
-
190
- Args:
191
- model: A SQLAlchemy model to create an interface for.
192
-
193
- Returns:
194
- A Pydantic model class with the same structure as the provided SQLAlchemy model.
195
- """
196
-
197
- fields = {
198
- col.name: (col.type.python_type, col.default if col.default is not None else ...)
199
- for col in model.__table__.columns
200
- }
201
-
202
- return create_model(model.__name__, **fields)
203
-
204
-
205
152
  def create_session_iterator(engine: DBEngine) -> Callable[[], DBSession]:
206
153
  """Create a generator for database sessions.
207
154
 
auto_rest/queries.py CHANGED
@@ -10,7 +10,7 @@ handling and provides a streamlined interface for database interactions.
10
10
  Query utilities seamlessly support synchronous and asynchronous session types.
11
11
 
12
12
  ```python
13
- query = select(SomeModel).where(SomeModel.id == item_id)
13
+ query = select(SomeTable).where(SomeTable.id == item_id)
14
14
 
15
15
  with Session(...) as sync_session:
16
16
  result = await execute_session_query(sync_session, query)
@@ -19,15 +19,18 @@ handling and provides a streamlined interface for database interactions.
19
19
  result = await execute_session_query(async_session, query)
20
20
  ```
21
21
  """
22
+ from typing import Literal
22
23
 
23
24
  from fastapi import HTTPException
24
- from sqlalchemy import Executable, Result
25
+ from sqlalchemy import asc, desc, Executable, Result, Select
25
26
  from sqlalchemy.ext.asyncio import AsyncSession
26
27
  from starlette import status
27
28
 
28
29
  from auto_rest.models import DBSession
29
30
 
30
31
  __all__ = [
32
+ "apply_ordering_params",
33
+ "apply_pagination_params",
31
34
  "commit_session",
32
35
  "delete_session_record",
33
36
  "execute_session_query",
@@ -35,6 +38,64 @@ __all__ = [
35
38
  ]
36
39
 
37
40
 
41
+ def apply_ordering_params(
42
+ query: Select,
43
+ order_by: str | None = None,
44
+ direction: Literal["desc", "asc"] = "asc"
45
+ ) -> Select:
46
+ """Apply ordering to a database query.
47
+
48
+ Returns a copy of the provided query with ordering parameters applied.
49
+
50
+ Args:
51
+ query: The database query to apply parameters to.
52
+ order_by: The name of the column to order by.
53
+ direction: The direction to order by (defaults to "asc").
54
+
55
+ Returns:
56
+ A copy of the query modified to return ordered values.
57
+ """
58
+
59
+ if order_by is None:
60
+ return query
61
+
62
+ if order_by not in query.columns:
63
+ raise ValueError(f"Invalid column name: {order_by}")
64
+
65
+ # Default to ascending order for an invalid ordering direction
66
+ if direction == "desc":
67
+ return query.order_by(desc(order_by))
68
+
69
+ elif direction == "asc":
70
+ return query.order_by(asc(order_by))
71
+
72
+ raise ValueError(f"Invalid direction, use 'asc' or 'desc': {direction}")
73
+
74
+
75
+ def apply_pagination_params(query: Select, limit: int = 0, offset: int = 0) -> Select:
76
+ """Apply pagination to a database query.
77
+
78
+ Returns a copy of the provided query with offset and limit parameters applied.
79
+
80
+ Args:
81
+ query: The database query to apply parameters to.
82
+ limit: The number of results to return.
83
+ offset: The offset to start with.
84
+
85
+ Returns:
86
+ A copy of the query modified to only return the paginated values.
87
+ """
88
+
89
+ if offset < 0 or limit < 0:
90
+ raise ValueError("Pagination parameters cannot be negative")
91
+
92
+ # Do not apply pagination if not requested
93
+ if limit == 0:
94
+ return query
95
+
96
+ return query.offset(offset or 0).limit(limit)
97
+
98
+
38
99
  async def commit_session(session: DBSession) -> None:
39
100
  """Commit a SQLAlchemy session.
40
101
 
@@ -101,7 +162,7 @@ def get_record_or_404(result: Result) -> any:
101
162
  HTTPException: If the record is not found.
102
163
  """
103
164
 
104
- if not (record := result.scalar_one_or_none()):
105
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Record not found")
165
+ if record := result.fetchone():
166
+ return record
106
167
 
107
- return record
168
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Record not found")
auto_rest/routers.py CHANGED
@@ -24,15 +24,15 @@ routers to be added directly to an API application instance.
24
24
  """
25
25
 
26
26
  from fastapi import APIRouter
27
- from sqlalchemy import MetaData
27
+ from sqlalchemy import MetaData, Table
28
28
  from starlette import status
29
29
 
30
30
  from auto_rest.handlers import *
31
- from auto_rest.models import DBEngine, DBModel
31
+ from auto_rest.models import DBEngine
32
32
 
33
33
  __all__ = [
34
34
  "create_meta_router",
35
- "create_model_router",
35
+ "create_table_router",
36
36
  "create_welcome_router",
37
37
  ]
38
38
 
@@ -45,7 +45,13 @@ def create_welcome_router() -> APIRouter:
45
45
  """
46
46
 
47
47
  router = APIRouter()
48
- router.add_api_route("/", create_welcome_handler(), methods=["GET"], include_in_schema=False)
48
+ router.add_api_route(
49
+ path="/",
50
+ methods=["GET"],
51
+ endpoint=create_welcome_handler(),
52
+ include_in_schema=False
53
+ )
54
+
49
55
  return router
50
56
 
51
57
 
@@ -68,95 +74,110 @@ def create_meta_router(engine: DBEngine, metadata: MetaData, name: str, version:
68
74
  router = APIRouter()
69
75
  tags = ["Application Metadata"]
70
76
 
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)
77
+ router.add_api_route(
78
+ path="/app",
79
+ methods=["GET"],
80
+ endpoint=create_about_handler(name, version),
81
+ summary="Fetch application metadata.",
82
+ tags=tags
83
+ )
84
+
85
+ router.add_api_route(
86
+ path="/engine",
87
+ methods=["GET"],
88
+ endpoint=create_engine_handler(engine),
89
+ summary="Fetch database metadata.",
90
+ tags=tags
91
+ )
92
+
93
+ router.add_api_route(
94
+ path="/schema",
95
+ methods=["GET"],
96
+ endpoint=create_schema_handler(metadata),
97
+ summary="Fetch the database schema.",
98
+ tags=tags
99
+ )
100
+
74
101
  return router
75
102
 
76
103
 
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.
104
+ def create_table_router(engine: DBEngine, table: Table, writeable: bool = False) -> APIRouter:
105
+ """Create an API router with endpoint handlers for a given database table.
79
106
 
80
107
  Args:
81
108
  engine: The SQLAlchemy engine connected to the database.
82
- model: The ORM model class representing a database table.
109
+ table: The database table to create API endpoints for.
83
110
  writeable: Whether the router should include support for write operations.
84
111
 
85
112
  Returns:
86
- An APIRouter instance with routes for database operations on the model.
113
+ An APIRouter instance with routes for database operations on the table.
87
114
  """
88
115
 
89
116
  router = APIRouter()
90
117
 
91
118
  # Construct path parameters from primary key columns
92
- pk_columns = sorted(column.name for column in model.__table__.primary_key.columns)
119
+ pk_columns = sorted(column.name for column in table.primary_key.columns)
93
120
  path_params_url = "/".join(f"{{{col_name}}}" for col_name in pk_columns)
94
- path_params_openapi = {
95
- "parameters": [
96
- {"name": col_name, "in": "path", "required": True} for col_name in pk_columns
97
- ]
98
- }
99
-
100
- # Raise an error if no primary key columns are found
101
- # (SQLAlchemy should ensure this never happens)
102
- if not pk_columns: # pragma: no cover
103
- raise RuntimeError(f"No primary key columns found for table {model.__tablename__}.")
104
-
105
- # Define routes for read operations
106
- router.add_api_route(
107
- path="/",
108
- methods=["GET"],
109
- endpoint=create_list_records_handler(engine, model),
110
- status_code=status.HTTP_200_OK,
111
- tags=[model.__name__],
112
- )
113
-
114
- router.add_api_route(
115
- path=f"/{path_params_url}/",
116
- methods=["GET"],
117
- endpoint=create_get_record_handler(engine, model),
118
- status_code=status.HTTP_200_OK,
119
- tags=[model.__name__],
120
- openapi_extra=path_params_openapi
121
- )
122
-
123
- if not writeable:
124
- return router
125
121
 
126
- # Define routes for write operations
122
+ # Add route for read operations against the table
127
123
  router.add_api_route(
128
124
  path="/",
129
- methods=["POST"],
130
- endpoint=create_post_record_handler(engine, model),
131
- status_code=status.HTTP_201_CREATED,
132
- tags=[model.__name__],
133
- )
134
-
135
- router.add_api_route(
136
- path=f"/{path_params_url}/",
137
- methods=["PUT"],
138
- endpoint=create_put_record_handler(engine, model),
139
- status_code=status.HTTP_200_OK,
140
- tags=[model.__name__],
141
- openapi_extra=path_params_openapi
142
- )
143
-
144
- router.add_api_route(
145
- path=f"/{path_params_url}/",
146
- methods=["PATCH"],
147
- endpoint=create_patch_record_handler(engine, model),
125
+ methods=["GET"],
126
+ endpoint=create_list_records_handler(engine, table),
148
127
  status_code=status.HTTP_200_OK,
149
- tags=[model.__name__],
150
- openapi_extra=path_params_openapi
128
+ summary="Fetch multiple records from the table.",
129
+ tags=[table.name],
151
130
  )
152
131
 
153
- router.add_api_route(
154
- path=f"/{path_params_url}/",
155
- methods=["DELETE"],
156
- endpoint=create_delete_record_handler(engine, model),
157
- status_code=status.HTTP_200_OK,
158
- tags=[model.__name__],
159
- openapi_extra=path_params_openapi
160
- )
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
+ )
142
+
143
+ # Add route for read operations against individual records
144
+ if pk_columns:
145
+ router.add_api_route(
146
+ path=f"/{path_params_url}/",
147
+ methods=["GET"],
148
+ endpoint=create_get_record_handler(engine, table),
149
+ status_code=status.HTTP_200_OK,
150
+ summary="Fetch a single record from the table.",
151
+ tags=[table.name],
152
+ )
153
+
154
+ # Add routes for write operations against individual records
155
+ if pk_columns and writeable:
156
+ router.add_api_route(
157
+ path=f"/{path_params_url}/",
158
+ methods=["PUT"],
159
+ endpoint=create_put_record_handler(engine, table),
160
+ status_code=status.HTTP_200_OK,
161
+ summary="Replace a single record in the table.",
162
+ tags=[table.name],
163
+ )
164
+
165
+ router.add_api_route(
166
+ path=f"/{path_params_url}/",
167
+ methods=["PATCH"],
168
+ endpoint=create_patch_record_handler(engine, table),
169
+ status_code=status.HTTP_200_OK,
170
+ summary="Update a single record in the table.",
171
+ tags=[table.name],
172
+ )
173
+
174
+ router.add_api_route(
175
+ path=f"/{path_params_url}/",
176
+ methods=["DELETE"],
177
+ endpoint=create_delete_record_handler(engine, table),
178
+ status_code=status.HTTP_200_OK,
179
+ summary="Delete a single record from the table.",
180
+ tags=[table.name],
181
+ )
161
182
 
162
183
  return router
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: auto-rest-api
3
- Version: 0.1.1
3
+ Version: 0.1.3
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
@@ -0,0 +1,14 @@
1
+ auto_rest/__init__.py,sha256=9ICmv2urSoAo856FJylKdorF19UsUGc4eyORYLptf1Q,69
2
+ auto_rest/__main__.py,sha256=lz6-LEpbu51Ah3QCzUzZhevjWhK5kmOxhuuqhX5t-io,3093
3
+ auto_rest/app.py,sha256=I6ZeHzKuhPTS1svLhMTvefjzTkMJgGpwVz1kdJ_9mtE,1887
4
+ auto_rest/cli.py,sha256=A7-kMTNNzqZB7jTUJBAVqfYm9RyYjENr0g_vK9JE0N4,5199
5
+ auto_rest/handlers.py,sha256=Z8DMv3KRYLBXlUKoE4e-2qE9YvG-IPgS57C0RpUDWv4,12603
6
+ auto_rest/interfaces.py,sha256=WB_0eMDjGF8DpnDN9INHqo7u4x3aklvzAYK4t3JwC7s,3779
7
+ auto_rest/models.py,sha256=HCJUQPBmkkfVFv9CRcPzMP66pQk_UIb3j3cdwHqodwE,5388
8
+ auto_rest/queries.py,sha256=Z2ATkcSYldV6BkcrmLogmBoDkPEkyFzFqI7qPcq86uc,4705
9
+ auto_rest/routers.py,sha256=RqBwLqVFU1OIFSCUuwOtTis8mT_zyWTaB_8SyIXjfe0,5727
10
+ auto_rest_api-0.1.3.dist-info/LICENSE.md,sha256=zFRw_u1mGSOH8GrpOu0L1P765aX9fB5UpKz06mTxAos,34893
11
+ auto_rest_api-0.1.3.dist-info/METADATA,sha256=Cs7u-2jZGTTlvBprPzdLVWl1uSAAjjLgeh5PuHo3tUk,2951
12
+ auto_rest_api-0.1.3.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
13
+ auto_rest_api-0.1.3.dist-info/entry_points.txt,sha256=zFynmBrHyYo3Ds0Uo4-bTFe1Tdr5mIXV4dPQOFb-W1w,53
14
+ auto_rest_api-0.1.3.dist-info/RECORD,,
auto_rest/params.py DELETED
@@ -1,175 +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
- from collections.abc import Callable
33
- from typing import Literal
34
-
35
- from fastapi import Depends, Query
36
- from sqlalchemy import asc, desc
37
- from sqlalchemy.sql.selectable import Select
38
- from starlette.responses import Response
39
-
40
- from .models import DBModel
41
-
42
- __all__ = [
43
- "apply_ordering_params",
44
- "apply_pagination_params",
45
- "create_ordering_dependency",
46
- "create_pagination_dependency",
47
- ]
48
-
49
-
50
- def create_ordering_dependency(model: type[DBModel]) -> Callable[..., dict]:
51
- """Create an injectable dependency for fetching ordering arguments from query parameters.
52
-
53
- Args:
54
- model: The database model to create the dependency for.
55
-
56
- Returns:
57
- An injectable FastAPI dependency.
58
- """
59
-
60
- columns = tuple(model.__table__.columns.keys())
61
-
62
- def get_ordering_params(
63
- _order_by_: Literal[*columns] = Query(None, description="The field name to sort by."),
64
- _direction_: Literal["asc", "desc"] = Query("asc", description="Sort results in 'asc' or 'desc' order.")
65
- ) -> dict:
66
- """Extract ordering parameters from request query parameters.
67
-
68
- Args:
69
- _order_by_: The field to order by.
70
- _direction_: The direction to order by.
71
-
72
- Returns:
73
- dict: A dictionary containing the `order_by` and `direction` values.
74
- """
75
-
76
- return {"order_by": _order_by_, "direction": _direction_}
77
-
78
- return Depends(get_ordering_params)
79
-
80
-
81
- def apply_ordering_params(query: Select, params: dict, response: Response) -> Select:
82
- """Apply ordering to a database query.
83
-
84
- Returns a copy of the provided query with ordering parameters applied.
85
- This method is compatible with parameters returned by the `get_ordering_params` method.
86
- Ordering is not applied for invalid params, but response headers are still set.
87
-
88
- Args:
89
- query: The database query to apply parameters to.
90
- params: A dictionary containing parsed URL parameters.
91
- response: The outgoing HTTP response object.
92
-
93
- Returns:
94
- A copy of the query modified to return ordered values.
95
- """
96
-
97
- order_by = params.get("order_by")
98
- direction = params.get("direction")
99
-
100
- # Set common response headers
101
- response.headers["X-Order-By"] = str(order_by)
102
- response.headers["X-Order-Direction"] = str(direction)
103
-
104
- if order_by is None:
105
- response.headers["X-Order-Applied"] = "false"
106
- return query
107
-
108
- # Default to ascending order for an invalid ordering direction
109
- response.headers["X-Order-Applied"] = "true"
110
- if direction == "desc":
111
- return query.order_by(desc(order_by))
112
-
113
- else:
114
- return query.order_by(asc(order_by))
115
-
116
-
117
- def create_pagination_dependency(model: type[DBModel]) -> Callable[..., dict]:
118
- """Create an injectable dependency for fetching pagination arguments from query parameters.
119
-
120
- Args:
121
- model: The database model to create the dependency for.
122
-
123
- Returns:
124
- An injectable FastAPI dependency.
125
- """
126
-
127
- def get_pagination_params(
128
- _limit_: int = Query(0, ge=0, description="The maximum number of records to return."),
129
- _offset_: int = Query(0, ge=0, description="The starting index of the returned records."),
130
- ) -> dict[str, int]:
131
- """Extract pagination parameters from request query parameters.
132
-
133
- Args:
134
- _limit_: The maximum number of records to return.
135
- _offset_: The starting index of the returned records.
136
-
137
- Returns:
138
- dict: A dictionary containing the `limit` and `offset` values.
139
- """
140
-
141
- return {"limit": _limit_, "offset": _offset_}
142
-
143
- return Depends(get_pagination_params)
144
-
145
-
146
- def apply_pagination_params(query: Select, params: dict[str, int], response: Response) -> Select:
147
- """Apply pagination to a database query.
148
-
149
- Returns a copy of the provided query with offset and limit parameters applied.
150
- This method is compatible with parameters returned by the `get_pagination_params` method.
151
- Pagination is not applied for invalid params, but response headers are still set.
152
-
153
- Args:
154
- query: The database query to apply parameters to.
155
- params: A dictionary containing parsed URL parameters.
156
- response: The outgoing HTTP response object.
157
-
158
- Returns:
159
- A copy of the query modified to only return the paginated values.
160
- """
161
-
162
- limit = params.get("limit")
163
- offset = params.get("offset")
164
-
165
- # Set common response headers
166
- response.headers["X-Pagination-Limit"] = str(limit)
167
- response.headers["X-Pagination-Offset"] = str(offset)
168
-
169
- # Do not apply pagination if not requested
170
- if limit in (0, None):
171
- response.headers["X-Pagination-Applied"] = "false"
172
- return query
173
-
174
- response.headers["X-Pagination-Applied"] = "true"
175
- return query.offset(offset or 0).limit(limit)
@@ -1,14 +0,0 @@
1
- auto_rest/__init__.py,sha256=9ICmv2urSoAo856FJylKdorF19UsUGc4eyORYLptf1Q,69
2
- auto_rest/__main__.py,sha256=XvDFalpJBQ0QvZDr9f3tn108Skf80aYrnK8iohYLwBk,3085
3
- auto_rest/app.py,sha256=I6ZeHzKuhPTS1svLhMTvefjzTkMJgGpwVz1kdJ_9mtE,1887
4
- auto_rest/cli.py,sha256=A7-kMTNNzqZB7jTUJBAVqfYm9RyYjENr0g_vK9JE0N4,5199
5
- auto_rest/handlers.py,sha256=QaqnYSxSBxB2YCffEf2uUlza6LplXBOPGoU2Yd8pe2k,11921
6
- auto_rest/models.py,sha256=_SCDNO9811uJWndMG4EaqTPUh4PptcyfHJXrdQ4jhWc,6925
7
- auto_rest/params.py,sha256=Qb3xS1t1rYDJD7C9095D4bZvZCKhH4za-lqwGKm2UrI,6067
8
- auto_rest/queries.py,sha256=GzoCtxRPn9UJ7cnhYuPc8wZFppbqe7ykSlnesfWSr94,2926
9
- auto_rest/routers.py,sha256=BN9B3Lzvme59a2a0DlLXYxosBCRjZtQsVxoljRggpyg,5326
10
- auto_rest_api-0.1.1.dist-info/LICENSE.md,sha256=zFRw_u1mGSOH8GrpOu0L1P765aX9fB5UpKz06mTxAos,34893
11
- auto_rest_api-0.1.1.dist-info/METADATA,sha256=snNqFpjnxbC2ntO5bhTETLtXAatgiiQYv9FL33n8G8s,2951
12
- auto_rest_api-0.1.1.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
13
- auto_rest_api-0.1.1.dist-info/entry_points.txt,sha256=zFynmBrHyYo3Ds0Uo4-bTFe1Tdr5mIXV4dPQOFb-W1w,53
14
- auto_rest_api-0.1.1.dist-info/RECORD,,