aws-python-helper 0.36.0__tar.gz → 0.38.0__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.
Files changed (53) hide show
  1. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/PKG-INFO +170 -1
  2. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/README.md +169 -0
  3. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/__init__.py +4 -0
  4. aws_python_helper-0.38.0/aws_python_helper/model_query/__init__.py +3 -0
  5. aws_python_helper-0.38.0/aws_python_helper/model_query/base.py +179 -0
  6. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper.egg-info/PKG-INFO +170 -1
  7. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper.egg-info/SOURCES.txt +2 -0
  8. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/pyproject.toml +1 -1
  9. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/api/__init__.py +0 -0
  10. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/api/auth_middleware.py +0 -0
  11. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/api/auth_validators.py +0 -0
  12. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/api/base.py +0 -0
  13. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/api/dispatcher.py +0 -0
  14. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/api/exceptions.py +0 -0
  15. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/api/fetcher.py +0 -0
  16. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/api/handler.py +0 -0
  17. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/context/__init__.py +0 -0
  18. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/context/session.py +0 -0
  19. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/context/state_validator.py +0 -0
  20. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/database/__init__.py +0 -0
  21. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/database/database_proxy.py +0 -0
  22. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/database/external_database_proxy.py +0 -0
  23. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/database/external_mongo_manager.py +0 -0
  24. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/database/mongo_manager.py +0 -0
  25. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/fargate/__init__.py +0 -0
  26. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/fargate/executor.py +0 -0
  27. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/fargate/fetcher.py +0 -0
  28. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/fargate/handler.py +0 -0
  29. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/fargate/task_base.py +0 -0
  30. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/invoker/__init__.py +0 -0
  31. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/invoker/api_client.py +0 -0
  32. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/invoker/exceptions.py +0 -0
  33. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/invoker/lambda_invoker.py +0 -0
  34. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/lambda_standalone/__init__.py +0 -0
  35. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/lambda_standalone/base.py +0 -0
  36. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/lambda_standalone/fetcher.py +0 -0
  37. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/lambda_standalone/handler.py +0 -0
  38. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/repository/__init__.py +0 -0
  39. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/repository/base.py +0 -0
  40. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/sns/__init__.py +0 -0
  41. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/sns/publisher.py +0 -0
  42. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/sqs/__init__.py +0 -0
  43. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/sqs/consumer_base.py +0 -0
  44. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/sqs/fetcher.py +0 -0
  45. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/sqs/handler.py +0 -0
  46. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/utils/__init__.py +0 -0
  47. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/utils/json_encoder.py +0 -0
  48. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/utils/response.py +0 -0
  49. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper/utils/serializer.py +0 -0
  50. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper.egg-info/dependency_links.txt +0 -0
  51. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper.egg-info/requires.txt +0 -0
  52. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/aws_python_helper.egg-info/top_level.txt +0 -0
  53. {aws_python_helper-0.36.0 → aws_python_helper-0.38.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-python-helper
3
- Version: 0.36.0
3
+ Version: 0.38.0
4
4
  Summary: AWS Python Helper Framework
5
5
  Author-email: Fabian Claros <neufabiae@gmail.com>
6
6
  License-Expression: MIT
@@ -38,6 +38,7 @@ Mini-framework to create REST APIs, SQS Consumers, SNS Publishers, Fargate Tasks
38
38
  - **Pydantic schema validation**: Declare a `schema` property on any `API` or `Lambda` and the framework validates automatically before `validate()` — returns 400 on failure
39
39
  - **LambdaInvoker**: Invoke other Lambda functions (sync or async) with built-in error handling
40
40
  - **ApiClient**: HTTP client for inter-service communication — resolves service URLs and auth token from environment variables automatically
41
+ - **ModelQueryLambda**: Base class for MongoDB query-proxy Lambdas — declare an `allowed_collections` whitelist and the framework handles `find` and `aggregate` operations generically
41
42
  - **JSON utilities**: Automatic serialization of MongoDB types
42
43
  - **Type hints**: Modern Python with type annotations
43
44
  - **Async/await**: Full support for asynchronous operations
@@ -65,6 +66,7 @@ All available classes and functions:
65
66
  | `FargateExecutor` | `aws_python_helper.fargate.executor` | Launches Fargate tasks from Lambda |
66
67
  | `fargate_handler` | `aws_python_helper.fargate.handler` | Entry point handler for Fargate |
67
68
  | `Repository` | `aws_python_helper.repository.base` | Base class for MongoDB repositories |
69
+ | `ModelQueryLambda` | `aws_python_helper` | Generic MongoDB query-proxy Lambda base class |
68
70
  | `LambdaInvoker` | `aws_python_helper` | Invoke Lambda functions (sync/async) with error handling |
69
71
  | `ApiClient` | `aws_python_helper` | HTTP client for inter-service communication |
70
72
  | `LambdaInvocationError` | `aws_python_helper` | Raised when boto3 fails to invoke a Lambda |
@@ -1164,6 +1166,173 @@ module "my_lambda" {
1164
1166
 
1165
1167
  ---
1166
1168
 
1169
+ ## 🔍 ModelQueryLambda
1170
+
1171
+ `ModelQueryLambda` is a ready-to-use base class for creating MongoDB query-proxy Lambdas. Instead of writing a custom Lambda every time another microservice needs to read data from your MongoDB, you subclass `ModelQueryLambda`, declare an `allowed_collections` whitelist, and the framework exposes both `find` and `aggregate` operations automatically.
1172
+
1173
+ **Use case:** microservice A needs to query data owned by microservice B. Microservice B deploys one `model-query` Lambda; microservice A calls it via `LambdaInvoker` passing the collection name and query parameters.
1174
+
1175
+ ### Creating a model-query Lambda
1176
+
1177
+ Create `src/lambda/model-query/main.py` in the microservice that owns the data:
1178
+
1179
+ ```python
1180
+ from aws_python_helper import ModelQueryLambda
1181
+
1182
+ class ModelQueryLambda(ModelQueryLambda):
1183
+
1184
+ @property
1185
+ def allowed_collections(self) -> list:
1186
+ return ["orders", "customers"] # whitelist — unlisted collections are rejected
1187
+ ```
1188
+
1189
+ Register the handler in `src/handlers/lambda_handler.py`:
1190
+
1191
+ ```python
1192
+ from aws_python_helper.lambda_standalone.handler import lambda_handler
1193
+
1194
+ model_query_handler = lambda_handler('model-query')
1195
+
1196
+ __all__ = ["model_query_handler"]
1197
+ ```
1198
+
1199
+ ### Query modes
1200
+
1201
+ #### Aggregate pipeline
1202
+
1203
+ Pass a `pipeline` list to run a MongoDB aggregation:
1204
+
1205
+ ```python
1206
+ invoker.invoke("ServiceModelQuery-dev", payload={
1207
+ "session": self.session.to_dict(),
1208
+ "collection": "orders",
1209
+ "pipeline": [
1210
+ {"$match": {"status": "pending"}},
1211
+ {"$sort": {"created_at": -1}},
1212
+ {"$limit": 50},
1213
+ ],
1214
+ })
1215
+ ```
1216
+
1217
+ #### Find with filter
1218
+
1219
+ Pass `filter`, `fields`, `limit`, and/or `skip` to run a `find`:
1220
+
1221
+ ```python
1222
+ invoker.invoke("ServiceModelQuery-dev", payload={
1223
+ "session": self.session.to_dict(),
1224
+ "collection": "orders",
1225
+ "filter": {"customer_id": "abc123", "status": "active"},
1226
+ "fields": {"_id": 1, "total": 1, "status": 1}, # projection
1227
+ "limit": 20,
1228
+ "skip": 0,
1229
+ })
1230
+ ```
1231
+
1232
+ **Note:** `pipeline` and `fields` are mutually exclusive — the framework rejects payloads that include both.
1233
+
1234
+ #### Querying by `_id`
1235
+
1236
+ ObjectId hex strings in `_id` filters are automatically coerced to `ObjectId` so callers can serialize queries as plain JSON:
1237
+
1238
+ ```python
1239
+ # Plain value
1240
+ invoker.invoke("ServiceModelQuery-dev", payload={
1241
+ "session": self.session.to_dict(),
1242
+ "collection": "orders",
1243
+ "filter": {"_id": "65f1a2b3c4d5e6f7a8b9c0d1"},
1244
+ })
1245
+
1246
+ # Operator with list
1247
+ invoker.invoke("ServiceModelQuery-dev", payload={
1248
+ "session": self.session.to_dict(),
1249
+ "collection": "orders",
1250
+ "filter": {"_id": {"$in": ["65f1...", "65f2...", "65f3..."]}},
1251
+ })
1252
+
1253
+ # 'id' is accepted as an alias for '_id'
1254
+ invoker.invoke("ServiceModelQuery-dev", payload={
1255
+ "session": self.session.to_dict(),
1256
+ "collection": "orders",
1257
+ "filter": {"id": "65f1a2b3c4d5e6f7a8b9c0d1"},
1258
+ })
1259
+ ```
1260
+
1261
+ Supported operators on `_id`: scalar (`$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`) and list (`$in`, `$nin`). The same coercion is applied to `$match` stages inside a `pipeline`. If the string is not a valid 24-char ObjectId hex, the Lambda raises a clear validation error.
1262
+
1263
+ ### Payload reference
1264
+
1265
+ | Field | Type | Required | Description |
1266
+ |-------|------|----------|-------------|
1267
+ | `session` | `dict` | Yes | Session dict from `self.session.to_dict()` — drives state-scoped DB routing |
1268
+ | `collection` | `str` | Yes | Collection name (must be in `allowed_collections`) |
1269
+ | `pipeline` | `list` | One of | MongoDB aggregation pipeline |
1270
+ | `filter` | `dict` | One of | MongoDB filter document (defaults to `{}`). `_id`/`id` string values are auto-coerced to `ObjectId` |
1271
+ | `fields` | `dict` | No | MongoDB projection (only valid with `filter` mode) |
1272
+ | `limit` | `int` | No | Max documents to return |
1273
+ | `skip` | `int` | No | Documents to skip (default `0`) |
1274
+
1275
+ ### Response format
1276
+
1277
+ The Lambda always returns the standard framework envelope:
1278
+
1279
+ ```json
1280
+ {
1281
+ "success": true,
1282
+ "data": [ ... ]
1283
+ }
1284
+ ```
1285
+
1286
+ On validation error (unknown collection, invalid payload):
1287
+
1288
+ ```json
1289
+ {
1290
+ "success": false,
1291
+ "error": "Collection 'unknown' is not allowed. Allowed: orders, customers"
1292
+ }
1293
+ ```
1294
+
1295
+ ### Calling it from another microservice
1296
+
1297
+ ```python
1298
+ import os
1299
+ from aws_python_helper import LambdaInvoker, LambdaInvocationError
1300
+
1301
+ class OrdersPostAPI(API):
1302
+
1303
+ async def process(self):
1304
+ try:
1305
+ response = LambdaInvoker().invoke(
1306
+ os.getenv("ORDERS_MODEL_QUERY_LAMBDA_NAME"),
1307
+ payload={
1308
+ "session": self.session.to_dict(),
1309
+ "collection": "orders",
1310
+ "filter": {"status": "pending"},
1311
+ "limit": 100,
1312
+ },
1313
+ )
1314
+ except LambdaInvocationError as e:
1315
+ self.logger.error(f"model-query invocation failed: {e}")
1316
+ raise ValueError("Could not retrieve orders. Please try again later.")
1317
+
1318
+ if not response.get("success"):
1319
+ raise ValueError("Could not retrieve orders. Please try again later.")
1320
+
1321
+ orders = response.get("data", [])
1322
+ self.set_body({"orders": orders})
1323
+ ```
1324
+
1325
+ ### Session and database routing
1326
+
1327
+ The `session` field in the payload is required. The `ModelQueryLambda` uses `session.state` to resolve the target database — the same state-scoped routing used by all framework components. This means:
1328
+
1329
+ - `{"session": {"state": "connecticut"}, ...}` → queries the `connecticut` database
1330
+ - `{"session": {"state": "new_jersey"}, ...}` → queries the `new_jersey` database
1331
+
1332
+ Always pass `self.session.to_dict()` when calling from an API or Lambda to ensure state propagates correctly.
1333
+
1334
+ ---
1335
+
1167
1336
  ## 🏗️ Architecture Overview
1168
1337
 
1169
1338
  Typical flow for event-driven architectures using this framework:
@@ -18,6 +18,7 @@ Mini-framework to create REST APIs, SQS Consumers, SNS Publishers, Fargate Tasks
18
18
  - **Pydantic schema validation**: Declare a `schema` property on any `API` or `Lambda` and the framework validates automatically before `validate()` — returns 400 on failure
19
19
  - **LambdaInvoker**: Invoke other Lambda functions (sync or async) with built-in error handling
20
20
  - **ApiClient**: HTTP client for inter-service communication — resolves service URLs and auth token from environment variables automatically
21
+ - **ModelQueryLambda**: Base class for MongoDB query-proxy Lambdas — declare an `allowed_collections` whitelist and the framework handles `find` and `aggregate` operations generically
21
22
  - **JSON utilities**: Automatic serialization of MongoDB types
22
23
  - **Type hints**: Modern Python with type annotations
23
24
  - **Async/await**: Full support for asynchronous operations
@@ -45,6 +46,7 @@ All available classes and functions:
45
46
  | `FargateExecutor` | `aws_python_helper.fargate.executor` | Launches Fargate tasks from Lambda |
46
47
  | `fargate_handler` | `aws_python_helper.fargate.handler` | Entry point handler for Fargate |
47
48
  | `Repository` | `aws_python_helper.repository.base` | Base class for MongoDB repositories |
49
+ | `ModelQueryLambda` | `aws_python_helper` | Generic MongoDB query-proxy Lambda base class |
48
50
  | `LambdaInvoker` | `aws_python_helper` | Invoke Lambda functions (sync/async) with error handling |
49
51
  | `ApiClient` | `aws_python_helper` | HTTP client for inter-service communication |
50
52
  | `LambdaInvocationError` | `aws_python_helper` | Raised when boto3 fails to invoke a Lambda |
@@ -1144,6 +1146,173 @@ module "my_lambda" {
1144
1146
 
1145
1147
  ---
1146
1148
 
1149
+ ## 🔍 ModelQueryLambda
1150
+
1151
+ `ModelQueryLambda` is a ready-to-use base class for creating MongoDB query-proxy Lambdas. Instead of writing a custom Lambda every time another microservice needs to read data from your MongoDB, you subclass `ModelQueryLambda`, declare an `allowed_collections` whitelist, and the framework exposes both `find` and `aggregate` operations automatically.
1152
+
1153
+ **Use case:** microservice A needs to query data owned by microservice B. Microservice B deploys one `model-query` Lambda; microservice A calls it via `LambdaInvoker` passing the collection name and query parameters.
1154
+
1155
+ ### Creating a model-query Lambda
1156
+
1157
+ Create `src/lambda/model-query/main.py` in the microservice that owns the data:
1158
+
1159
+ ```python
1160
+ from aws_python_helper import ModelQueryLambda
1161
+
1162
+ class ModelQueryLambda(ModelQueryLambda):
1163
+
1164
+ @property
1165
+ def allowed_collections(self) -> list:
1166
+ return ["orders", "customers"] # whitelist — unlisted collections are rejected
1167
+ ```
1168
+
1169
+ Register the handler in `src/handlers/lambda_handler.py`:
1170
+
1171
+ ```python
1172
+ from aws_python_helper.lambda_standalone.handler import lambda_handler
1173
+
1174
+ model_query_handler = lambda_handler('model-query')
1175
+
1176
+ __all__ = ["model_query_handler"]
1177
+ ```
1178
+
1179
+ ### Query modes
1180
+
1181
+ #### Aggregate pipeline
1182
+
1183
+ Pass a `pipeline` list to run a MongoDB aggregation:
1184
+
1185
+ ```python
1186
+ invoker.invoke("ServiceModelQuery-dev", payload={
1187
+ "session": self.session.to_dict(),
1188
+ "collection": "orders",
1189
+ "pipeline": [
1190
+ {"$match": {"status": "pending"}},
1191
+ {"$sort": {"created_at": -1}},
1192
+ {"$limit": 50},
1193
+ ],
1194
+ })
1195
+ ```
1196
+
1197
+ #### Find with filter
1198
+
1199
+ Pass `filter`, `fields`, `limit`, and/or `skip` to run a `find`:
1200
+
1201
+ ```python
1202
+ invoker.invoke("ServiceModelQuery-dev", payload={
1203
+ "session": self.session.to_dict(),
1204
+ "collection": "orders",
1205
+ "filter": {"customer_id": "abc123", "status": "active"},
1206
+ "fields": {"_id": 1, "total": 1, "status": 1}, # projection
1207
+ "limit": 20,
1208
+ "skip": 0,
1209
+ })
1210
+ ```
1211
+
1212
+ **Note:** `pipeline` and `fields` are mutually exclusive — the framework rejects payloads that include both.
1213
+
1214
+ #### Querying by `_id`
1215
+
1216
+ ObjectId hex strings in `_id` filters are automatically coerced to `ObjectId` so callers can serialize queries as plain JSON:
1217
+
1218
+ ```python
1219
+ # Plain value
1220
+ invoker.invoke("ServiceModelQuery-dev", payload={
1221
+ "session": self.session.to_dict(),
1222
+ "collection": "orders",
1223
+ "filter": {"_id": "65f1a2b3c4d5e6f7a8b9c0d1"},
1224
+ })
1225
+
1226
+ # Operator with list
1227
+ invoker.invoke("ServiceModelQuery-dev", payload={
1228
+ "session": self.session.to_dict(),
1229
+ "collection": "orders",
1230
+ "filter": {"_id": {"$in": ["65f1...", "65f2...", "65f3..."]}},
1231
+ })
1232
+
1233
+ # 'id' is accepted as an alias for '_id'
1234
+ invoker.invoke("ServiceModelQuery-dev", payload={
1235
+ "session": self.session.to_dict(),
1236
+ "collection": "orders",
1237
+ "filter": {"id": "65f1a2b3c4d5e6f7a8b9c0d1"},
1238
+ })
1239
+ ```
1240
+
1241
+ Supported operators on `_id`: scalar (`$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`) and list (`$in`, `$nin`). The same coercion is applied to `$match` stages inside a `pipeline`. If the string is not a valid 24-char ObjectId hex, the Lambda raises a clear validation error.
1242
+
1243
+ ### Payload reference
1244
+
1245
+ | Field | Type | Required | Description |
1246
+ |-------|------|----------|-------------|
1247
+ | `session` | `dict` | Yes | Session dict from `self.session.to_dict()` — drives state-scoped DB routing |
1248
+ | `collection` | `str` | Yes | Collection name (must be in `allowed_collections`) |
1249
+ | `pipeline` | `list` | One of | MongoDB aggregation pipeline |
1250
+ | `filter` | `dict` | One of | MongoDB filter document (defaults to `{}`). `_id`/`id` string values are auto-coerced to `ObjectId` |
1251
+ | `fields` | `dict` | No | MongoDB projection (only valid with `filter` mode) |
1252
+ | `limit` | `int` | No | Max documents to return |
1253
+ | `skip` | `int` | No | Documents to skip (default `0`) |
1254
+
1255
+ ### Response format
1256
+
1257
+ The Lambda always returns the standard framework envelope:
1258
+
1259
+ ```json
1260
+ {
1261
+ "success": true,
1262
+ "data": [ ... ]
1263
+ }
1264
+ ```
1265
+
1266
+ On validation error (unknown collection, invalid payload):
1267
+
1268
+ ```json
1269
+ {
1270
+ "success": false,
1271
+ "error": "Collection 'unknown' is not allowed. Allowed: orders, customers"
1272
+ }
1273
+ ```
1274
+
1275
+ ### Calling it from another microservice
1276
+
1277
+ ```python
1278
+ import os
1279
+ from aws_python_helper import LambdaInvoker, LambdaInvocationError
1280
+
1281
+ class OrdersPostAPI(API):
1282
+
1283
+ async def process(self):
1284
+ try:
1285
+ response = LambdaInvoker().invoke(
1286
+ os.getenv("ORDERS_MODEL_QUERY_LAMBDA_NAME"),
1287
+ payload={
1288
+ "session": self.session.to_dict(),
1289
+ "collection": "orders",
1290
+ "filter": {"status": "pending"},
1291
+ "limit": 100,
1292
+ },
1293
+ )
1294
+ except LambdaInvocationError as e:
1295
+ self.logger.error(f"model-query invocation failed: {e}")
1296
+ raise ValueError("Could not retrieve orders. Please try again later.")
1297
+
1298
+ if not response.get("success"):
1299
+ raise ValueError("Could not retrieve orders. Please try again later.")
1300
+
1301
+ orders = response.get("data", [])
1302
+ self.set_body({"orders": orders})
1303
+ ```
1304
+
1305
+ ### Session and database routing
1306
+
1307
+ The `session` field in the payload is required. The `ModelQueryLambda` uses `session.state` to resolve the target database — the same state-scoped routing used by all framework components. This means:
1308
+
1309
+ - `{"session": {"state": "connecticut"}, ...}` → queries the `connecticut` database
1310
+ - `{"session": {"state": "new_jersey"}, ...}` → queries the `new_jersey` database
1311
+
1312
+ Always pass `self.session.to_dict()` when calling from an API or Lambda to ensure state propagates correctly.
1313
+
1314
+ ---
1315
+
1147
1316
  ## 🏗️ Architecture Overview
1148
1317
 
1149
1318
  Typical flow for event-driven architectures using this framework:
@@ -31,6 +31,9 @@ from .repository.base import Repository
31
31
  from .context.session import Session, get_session, set_session
32
32
  from .context.state_validator import StateValidator, InvalidStateError
33
33
 
34
+ # Model Query
35
+ from .model_query import ModelQueryLambda
36
+
34
37
  # Invoker
35
38
  from .invoker import (
36
39
  LambdaInvoker,
@@ -65,6 +68,7 @@ __all__ = [
65
68
  'set_session',
66
69
  'StateValidator',
67
70
  'InvalidStateError',
71
+ 'ModelQueryLambda',
68
72
  'LambdaInvoker',
69
73
  'ApiClient',
70
74
  'LambdaInvocationError',
@@ -0,0 +1,3 @@
1
+ from .base import ModelQueryLambda
2
+
3
+ __all__ = ["ModelQueryLambda"]
@@ -0,0 +1,179 @@
1
+ """
2
+ ModelQueryLambda - Generic MongoDB query proxy Lambda for inter-service communication.
3
+
4
+ Allows other microservices to query any whitelisted collection in this service's
5
+ MongoDB database by invoking this Lambda directly, passing standard MongoDB
6
+ query parameters (filter, pipeline, fields, limit, skip).
7
+
8
+ Usage — subclass and declare which collections are exposed:
9
+
10
+ from aws_python_helper import ModelQueryLambda
11
+
12
+ class DocketsModelQueryLambda(ModelQueryLambda):
13
+ @property
14
+ def allowed_collections(self) -> list:
15
+ return ["dockets", "tax_sales"]
16
+
17
+ Caller payload:
18
+ {
19
+ "session": {"state": "connecticut"},
20
+ "collection": "dockets",
21
+ "filter": {"case_type_code": "M10"}, # simple find — option A
22
+ "pipeline": [...], # aggregate — option B (takes precedence)
23
+ "fields": {"docket_id": 1, "_id": 0}, # projection (only with filter)
24
+ "limit": 100, # optional
25
+ "skip": 0 # optional, default 0
26
+ }
27
+
28
+ ObjectId coercion:
29
+ String values for '_id' (or 'id' as alias) inside `filter` and pipeline
30
+ `$match` stages are automatically converted to bson.ObjectId. Supports
31
+ scalar operators ($eq, $ne, $gt, $gte, $lt, $lte) and list operators
32
+ ($in, $nin). A clear ValueError is raised when the string is not a valid
33
+ 24-char ObjectId hex.
34
+
35
+ {"filter": {"_id": "65f1a2b3c4d5e6f7a8b9c0d1"}}
36
+ {"filter": {"_id": {"$in": ["65f1...", "65f2..."]}}}
37
+ {"filter": {"id": "65f1a2b3c4d5e6f7a8b9c0d1"}} # 'id' is aliased to '_id'
38
+
39
+ Response (via Lambda base run()):
40
+ {"success": True, "data": [...]}
41
+ """
42
+
43
+ from typing import Any, List, Optional
44
+ from bson import ObjectId
45
+ from pydantic import BaseModel, ConfigDict, model_validator
46
+
47
+ from ..lambda_standalone.base import Lambda
48
+
49
+
50
+ _OBJECT_ID_SCALAR_OPERATORS = {"$eq", "$ne", "$gt", "$gte", "$lt", "$lte"}
51
+ _OBJECT_ID_LIST_OPERATORS = {"$in", "$nin"}
52
+
53
+
54
+ def _coerce_object_id(value: Any) -> Any:
55
+ """Convert a string (or operator dict) to ObjectId where applicable for an '_id' field."""
56
+ if isinstance(value, ObjectId):
57
+ return value
58
+ if isinstance(value, str):
59
+ if not ObjectId.is_valid(value):
60
+ raise ValueError(f"'_id' value '{value}' is not a valid ObjectId")
61
+ return ObjectId(value)
62
+ if isinstance(value, dict):
63
+ coerced = {}
64
+ for op, op_value in value.items():
65
+ if op in _OBJECT_ID_LIST_OPERATORS:
66
+ if not isinstance(op_value, list):
67
+ raise ValueError(f"'{op}' value for '_id' must be a list")
68
+ coerced[op] = [_coerce_object_id(v) for v in op_value]
69
+ elif op in _OBJECT_ID_SCALAR_OPERATORS:
70
+ coerced[op] = _coerce_object_id(op_value)
71
+ else:
72
+ coerced[op] = op_value
73
+ return coerced
74
+ raise ValueError(
75
+ f"Unsupported type for '_id' filter: {type(value).__name__}"
76
+ )
77
+
78
+
79
+ def _normalize_id_key(d: dict) -> None:
80
+ """Rename 'id' → '_id' (if no _id present) and coerce its value to ObjectId in-place."""
81
+ if "id" in d and "_id" not in d:
82
+ d["_id"] = d.pop("id")
83
+ if "_id" in d:
84
+ d["_id"] = _coerce_object_id(d["_id"])
85
+
86
+
87
+ class _ModelQuerySchema(BaseModel):
88
+ model_config = ConfigDict(extra="ignore", arbitrary_types_allowed=True)
89
+
90
+ collection: str
91
+ filter: Optional[dict] = None
92
+ pipeline: Optional[list] = None
93
+ fields: Optional[dict] = None
94
+ limit: Optional[int] = None
95
+ skip: int = 0
96
+
97
+ @model_validator(mode="after")
98
+ def _pipeline_fields_exclusive(self):
99
+ if self.pipeline is not None and self.fields is not None:
100
+ raise ValueError("'fields' cannot be used together with 'pipeline'")
101
+ return self
102
+
103
+ @model_validator(mode="after")
104
+ def _normalize_object_ids(self):
105
+ if self.filter is not None:
106
+ _normalize_id_key(self.filter)
107
+ if self.pipeline is not None:
108
+ for stage in self.pipeline:
109
+ if isinstance(stage, dict) and isinstance(stage.get("$match"), dict):
110
+ _normalize_id_key(stage["$match"])
111
+ return self
112
+
113
+
114
+ class ModelQueryLambda(Lambda):
115
+ """
116
+ Base class for cross-service MongoDB query Lambdas.
117
+
118
+ Subclass and override `allowed_collections` to declare which collections
119
+ this Lambda exposes. Everything else is handled automatically.
120
+ """
121
+
122
+ @property
123
+ def allowed_collections(self) -> List[str]:
124
+ """
125
+ Whitelist of collection names this Lambda is allowed to query.
126
+
127
+ Must be overridden — an empty list rejects all requests.
128
+
129
+ Example:
130
+ @property
131
+ def allowed_collections(self):
132
+ return ["dockets", "tax_sales"]
133
+ """
134
+ return []
135
+
136
+ @property
137
+ def schema(self):
138
+ return _ModelQuerySchema
139
+
140
+ async def validate(self):
141
+ collection = self.data.get("collection", "")
142
+
143
+ if not self.allowed_collections:
144
+ raise ValueError(
145
+ "allowed_collections is not configured on this ModelQueryLambda"
146
+ )
147
+
148
+ if collection not in self.allowed_collections:
149
+ allowed = ", ".join(self.allowed_collections)
150
+ raise ValueError(
151
+ f"Collection '{collection}' is not allowed. Allowed: {allowed}"
152
+ )
153
+
154
+ async def process(self) -> Any:
155
+ collection_name: str = self.data["collection"]
156
+ database: str = self.data.get("database") or self.session.state
157
+
158
+ collection = getattr(getattr(self.db, database), collection_name)
159
+
160
+ pipeline: Optional[list] = self.data.get("pipeline")
161
+
162
+ if pipeline is not None:
163
+ results = await collection.aggregate(pipeline).to_list(length=None)
164
+ else:
165
+ mongo_filter: dict = self.data.get("filter") or {}
166
+ fields: Optional[dict] = self.data.get("fields")
167
+ limit: int = self.data.get("limit") or 0
168
+ skip: int = self.data.get("skip") or 0
169
+
170
+ cursor = collection.find(mongo_filter, fields or {})
171
+
172
+ if skip:
173
+ cursor = cursor.skip(skip)
174
+ if limit:
175
+ cursor = cursor.limit(limit)
176
+
177
+ results = await cursor.to_list(length=None)
178
+
179
+ return results
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-python-helper
3
- Version: 0.36.0
3
+ Version: 0.38.0
4
4
  Summary: AWS Python Helper Framework
5
5
  Author-email: Fabian Claros <neufabiae@gmail.com>
6
6
  License-Expression: MIT
@@ -38,6 +38,7 @@ Mini-framework to create REST APIs, SQS Consumers, SNS Publishers, Fargate Tasks
38
38
  - **Pydantic schema validation**: Declare a `schema` property on any `API` or `Lambda` and the framework validates automatically before `validate()` — returns 400 on failure
39
39
  - **LambdaInvoker**: Invoke other Lambda functions (sync or async) with built-in error handling
40
40
  - **ApiClient**: HTTP client for inter-service communication — resolves service URLs and auth token from environment variables automatically
41
+ - **ModelQueryLambda**: Base class for MongoDB query-proxy Lambdas — declare an `allowed_collections` whitelist and the framework handles `find` and `aggregate` operations generically
41
42
  - **JSON utilities**: Automatic serialization of MongoDB types
42
43
  - **Type hints**: Modern Python with type annotations
43
44
  - **Async/await**: Full support for asynchronous operations
@@ -65,6 +66,7 @@ All available classes and functions:
65
66
  | `FargateExecutor` | `aws_python_helper.fargate.executor` | Launches Fargate tasks from Lambda |
66
67
  | `fargate_handler` | `aws_python_helper.fargate.handler` | Entry point handler for Fargate |
67
68
  | `Repository` | `aws_python_helper.repository.base` | Base class for MongoDB repositories |
69
+ | `ModelQueryLambda` | `aws_python_helper` | Generic MongoDB query-proxy Lambda base class |
68
70
  | `LambdaInvoker` | `aws_python_helper` | Invoke Lambda functions (sync/async) with error handling |
69
71
  | `ApiClient` | `aws_python_helper` | HTTP client for inter-service communication |
70
72
  | `LambdaInvocationError` | `aws_python_helper` | Raised when boto3 fails to invoke a Lambda |
@@ -1164,6 +1166,173 @@ module "my_lambda" {
1164
1166
 
1165
1167
  ---
1166
1168
 
1169
+ ## 🔍 ModelQueryLambda
1170
+
1171
+ `ModelQueryLambda` is a ready-to-use base class for creating MongoDB query-proxy Lambdas. Instead of writing a custom Lambda every time another microservice needs to read data from your MongoDB, you subclass `ModelQueryLambda`, declare an `allowed_collections` whitelist, and the framework exposes both `find` and `aggregate` operations automatically.
1172
+
1173
+ **Use case:** microservice A needs to query data owned by microservice B. Microservice B deploys one `model-query` Lambda; microservice A calls it via `LambdaInvoker` passing the collection name and query parameters.
1174
+
1175
+ ### Creating a model-query Lambda
1176
+
1177
+ Create `src/lambda/model-query/main.py` in the microservice that owns the data:
1178
+
1179
+ ```python
1180
+ from aws_python_helper import ModelQueryLambda
1181
+
1182
+ class ModelQueryLambda(ModelQueryLambda):
1183
+
1184
+ @property
1185
+ def allowed_collections(self) -> list:
1186
+ return ["orders", "customers"] # whitelist — unlisted collections are rejected
1187
+ ```
1188
+
1189
+ Register the handler in `src/handlers/lambda_handler.py`:
1190
+
1191
+ ```python
1192
+ from aws_python_helper.lambda_standalone.handler import lambda_handler
1193
+
1194
+ model_query_handler = lambda_handler('model-query')
1195
+
1196
+ __all__ = ["model_query_handler"]
1197
+ ```
1198
+
1199
+ ### Query modes
1200
+
1201
+ #### Aggregate pipeline
1202
+
1203
+ Pass a `pipeline` list to run a MongoDB aggregation:
1204
+
1205
+ ```python
1206
+ invoker.invoke("ServiceModelQuery-dev", payload={
1207
+ "session": self.session.to_dict(),
1208
+ "collection": "orders",
1209
+ "pipeline": [
1210
+ {"$match": {"status": "pending"}},
1211
+ {"$sort": {"created_at": -1}},
1212
+ {"$limit": 50},
1213
+ ],
1214
+ })
1215
+ ```
1216
+
1217
+ #### Find with filter
1218
+
1219
+ Pass `filter`, `fields`, `limit`, and/or `skip` to run a `find`:
1220
+
1221
+ ```python
1222
+ invoker.invoke("ServiceModelQuery-dev", payload={
1223
+ "session": self.session.to_dict(),
1224
+ "collection": "orders",
1225
+ "filter": {"customer_id": "abc123", "status": "active"},
1226
+ "fields": {"_id": 1, "total": 1, "status": 1}, # projection
1227
+ "limit": 20,
1228
+ "skip": 0,
1229
+ })
1230
+ ```
1231
+
1232
+ **Note:** `pipeline` and `fields` are mutually exclusive — the framework rejects payloads that include both.
1233
+
1234
+ #### Querying by `_id`
1235
+
1236
+ ObjectId hex strings in `_id` filters are automatically coerced to `ObjectId` so callers can serialize queries as plain JSON:
1237
+
1238
+ ```python
1239
+ # Plain value
1240
+ invoker.invoke("ServiceModelQuery-dev", payload={
1241
+ "session": self.session.to_dict(),
1242
+ "collection": "orders",
1243
+ "filter": {"_id": "65f1a2b3c4d5e6f7a8b9c0d1"},
1244
+ })
1245
+
1246
+ # Operator with list
1247
+ invoker.invoke("ServiceModelQuery-dev", payload={
1248
+ "session": self.session.to_dict(),
1249
+ "collection": "orders",
1250
+ "filter": {"_id": {"$in": ["65f1...", "65f2...", "65f3..."]}},
1251
+ })
1252
+
1253
+ # 'id' is accepted as an alias for '_id'
1254
+ invoker.invoke("ServiceModelQuery-dev", payload={
1255
+ "session": self.session.to_dict(),
1256
+ "collection": "orders",
1257
+ "filter": {"id": "65f1a2b3c4d5e6f7a8b9c0d1"},
1258
+ })
1259
+ ```
1260
+
1261
+ Supported operators on `_id`: scalar (`$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`) and list (`$in`, `$nin`). The same coercion is applied to `$match` stages inside a `pipeline`. If the string is not a valid 24-char ObjectId hex, the Lambda raises a clear validation error.
1262
+
1263
+ ### Payload reference
1264
+
1265
+ | Field | Type | Required | Description |
1266
+ |-------|------|----------|-------------|
1267
+ | `session` | `dict` | Yes | Session dict from `self.session.to_dict()` — drives state-scoped DB routing |
1268
+ | `collection` | `str` | Yes | Collection name (must be in `allowed_collections`) |
1269
+ | `pipeline` | `list` | One of | MongoDB aggregation pipeline |
1270
+ | `filter` | `dict` | One of | MongoDB filter document (defaults to `{}`). `_id`/`id` string values are auto-coerced to `ObjectId` |
1271
+ | `fields` | `dict` | No | MongoDB projection (only valid with `filter` mode) |
1272
+ | `limit` | `int` | No | Max documents to return |
1273
+ | `skip` | `int` | No | Documents to skip (default `0`) |
1274
+
1275
+ ### Response format
1276
+
1277
+ The Lambda always returns the standard framework envelope:
1278
+
1279
+ ```json
1280
+ {
1281
+ "success": true,
1282
+ "data": [ ... ]
1283
+ }
1284
+ ```
1285
+
1286
+ On validation error (unknown collection, invalid payload):
1287
+
1288
+ ```json
1289
+ {
1290
+ "success": false,
1291
+ "error": "Collection 'unknown' is not allowed. Allowed: orders, customers"
1292
+ }
1293
+ ```
1294
+
1295
+ ### Calling it from another microservice
1296
+
1297
+ ```python
1298
+ import os
1299
+ from aws_python_helper import LambdaInvoker, LambdaInvocationError
1300
+
1301
+ class OrdersPostAPI(API):
1302
+
1303
+ async def process(self):
1304
+ try:
1305
+ response = LambdaInvoker().invoke(
1306
+ os.getenv("ORDERS_MODEL_QUERY_LAMBDA_NAME"),
1307
+ payload={
1308
+ "session": self.session.to_dict(),
1309
+ "collection": "orders",
1310
+ "filter": {"status": "pending"},
1311
+ "limit": 100,
1312
+ },
1313
+ )
1314
+ except LambdaInvocationError as e:
1315
+ self.logger.error(f"model-query invocation failed: {e}")
1316
+ raise ValueError("Could not retrieve orders. Please try again later.")
1317
+
1318
+ if not response.get("success"):
1319
+ raise ValueError("Could not retrieve orders. Please try again later.")
1320
+
1321
+ orders = response.get("data", [])
1322
+ self.set_body({"orders": orders})
1323
+ ```
1324
+
1325
+ ### Session and database routing
1326
+
1327
+ The `session` field in the payload is required. The `ModelQueryLambda` uses `session.state` to resolve the target database — the same state-scoped routing used by all framework components. This means:
1328
+
1329
+ - `{"session": {"state": "connecticut"}, ...}` → queries the `connecticut` database
1330
+ - `{"session": {"state": "new_jersey"}, ...}` → queries the `new_jersey` database
1331
+
1332
+ Always pass `self.session.to_dict()` when calling from an API or Lambda to ensure state propagates correctly.
1333
+
1334
+ ---
1335
+
1167
1336
  ## 🏗️ Architecture Overview
1168
1337
 
1169
1338
  Typical flow for event-driven architectures using this framework:
@@ -35,6 +35,8 @@ aws_python_helper/lambda_standalone/__init__.py
35
35
  aws_python_helper/lambda_standalone/base.py
36
36
  aws_python_helper/lambda_standalone/fetcher.py
37
37
  aws_python_helper/lambda_standalone/handler.py
38
+ aws_python_helper/model_query/__init__.py
39
+ aws_python_helper/model_query/base.py
38
40
  aws_python_helper/repository/__init__.py
39
41
  aws_python_helper/repository/base.py
40
42
  aws_python_helper/sns/__init__.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aws-python-helper"
7
- version = "0.36.0"
7
+ version = "0.38.0"
8
8
  description = "AWS Python Helper Framework"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"