aws-python-helper 0.30.0__tar.gz → 0.31.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 (47) hide show
  1. aws_python_helper-0.31.0/PKG-INFO +1197 -0
  2. aws_python_helper-0.31.0/README.md +1177 -0
  3. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/__init__.py +4 -0
  4. aws_python_helper-0.31.0/aws_python_helper/repository/__init__.py +3 -0
  5. aws_python_helper-0.31.0/aws_python_helper/repository/base.py +188 -0
  6. aws_python_helper-0.31.0/aws_python_helper.egg-info/PKG-INFO +1197 -0
  7. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper.egg-info/SOURCES.txt +2 -0
  8. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/pyproject.toml +1 -1
  9. aws_python_helper-0.30.0/PKG-INFO +0 -712
  10. aws_python_helper-0.30.0/README.md +0 -692
  11. aws_python_helper-0.30.0/aws_python_helper.egg-info/PKG-INFO +0 -712
  12. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/api/__init__.py +0 -0
  13. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/api/auth_middleware.py +0 -0
  14. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/api/auth_validators.py +0 -0
  15. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/api/base.py +0 -0
  16. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/api/dispatcher.py +0 -0
  17. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/api/exceptions.py +0 -0
  18. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/api/fetcher.py +0 -0
  19. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/api/handler.py +0 -0
  20. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/database/__init__.py +0 -0
  21. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/database/database_proxy.py +0 -0
  22. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/database/external_database_proxy.py +0 -0
  23. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/database/external_mongo_manager.py +0 -0
  24. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/database/mongo_manager.py +0 -0
  25. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/fargate/__init__.py +0 -0
  26. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/fargate/executor.py +0 -0
  27. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/fargate/fetcher.py +0 -0
  28. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/fargate/handler.py +0 -0
  29. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/fargate/task_base.py +0 -0
  30. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/lambda_standalone/__init__.py +0 -0
  31. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/lambda_standalone/base.py +0 -0
  32. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/lambda_standalone/fetcher.py +0 -0
  33. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/lambda_standalone/handler.py +0 -0
  34. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/sns/__init__.py +0 -0
  35. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/sns/publisher.py +0 -0
  36. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/sqs/__init__.py +0 -0
  37. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/sqs/consumer_base.py +0 -0
  38. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/sqs/fetcher.py +0 -0
  39. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/sqs/handler.py +0 -0
  40. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/utils/__init__.py +0 -0
  41. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/utils/json_encoder.py +0 -0
  42. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/utils/response.py +0 -0
  43. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/utils/serializer.py +0 -0
  44. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper.egg-info/dependency_links.txt +0 -0
  45. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper.egg-info/requires.txt +0 -0
  46. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper.egg-info/top_level.txt +0 -0
  47. {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/setup.cfg +0 -0
@@ -0,0 +1,1197 @@
1
+ Metadata-Version: 2.4
2
+ Name: aws-python-helper
3
+ Version: 0.31.0
4
+ Summary: AWS Python Helper Framework
5
+ Author-email: Fabian Claros <neufabiae@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/fabiae/aws-python-framework
8
+ Project-URL: Source Code, https://github.com/fabiae/aws-python-framework
9
+ Project-URL: Bug Tracker, https://github.com/fabiae/aws-python-framework/issues
10
+ Project-URL: Documentation, https://github.com/fabiae/aws-python-framework/blob/main/README.md
11
+ Keywords: aws,python,framework,helper,mongodb,sqs,sns,fargate,lambda
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: motor==3.3.2
18
+ Requires-Dist: pymongo==4.6.1
19
+ Requires-Dist: bcrypt>=4.0.0
20
+
21
+ # AWS Python Framework
22
+
23
+ Mini-framework to create REST APIs, SQS Consumers, SNS Publishers, Fargate Tasks, and Standalone Lambdas with Python in AWS Lambda.
24
+
25
+ ## 🚀 Features
26
+
27
+ - **Reusable single handler**: A single handler for all your API routes
28
+ - **Dynamic controller loading**: Routing based on convention
29
+ - **OOP structure**: Object-oriented programming for your code
30
+ - **Flexible MongoDB**: Direct access to multiple databases without models
31
+ - **External MongoDB**: Connect to multiple MongoDB clusters simultaneously
32
+ - **SQS Consumers**: Same pattern to process SQS messages (single or batch mode)
33
+ - **SNS Publishers**: Same pattern to publish messages to SNS topics
34
+ - **Fargate Tasks**: Same pattern to run tasks in Fargate containers
35
+ - **Standalone Lambdas**: Create lambdas invocable directly with AWS SDK
36
+ - **Authentication middleware**: Built-in token-based authentication
37
+ - **JSON utilities**: Automatic serialization of MongoDB types
38
+ - **Type hints**: Modern Python with type annotations
39
+ - **Async/await**: Full support for asynchronous operations
40
+
41
+ ## 🔧 Installation
42
+
43
+ ```bash
44
+ pip install aws-python-helper
45
+ ```
46
+
47
+ ## 📦 Quick Reference
48
+
49
+ All available classes and functions:
50
+
51
+ | Class / Function | Import | Purpose |
52
+ |------------------|--------|---------|
53
+ | `API` | `aws_python_helper.api.base` | Base class for REST endpoints |
54
+ | `api_handler` | `aws_python_helper.api.handler` | Generic handler for API Gateway |
55
+ | `SQSConsumer` | `aws_python_helper.sqs.consumer_base` | Base class for SQS consumers |
56
+ | `sqs_handler` | `aws_python_helper.sqs.handler` | Factory handler for SQS |
57
+ | `SNSPublisher` | `aws_python_helper.sns.publisher` | Base class for SNS publishers |
58
+ | `Lambda` | `aws_python_helper.lambda_standalone.base` | Base class for Standalone Lambdas |
59
+ | `lambda_handler` | `aws_python_helper.lambda_standalone.handler` | Factory handler for Lambda |
60
+ | `FargateTask` | `aws_python_helper.fargate.task_base` | Base class for Fargate tasks |
61
+ | `FargateExecutor` | `aws_python_helper.fargate.executor` | Launches Fargate tasks from Lambda |
62
+ | `fargate_handler` | `aws_python_helper.fargate.handler` | Entry point handler for Fargate |
63
+ | `Repository` | `aws_python_helper.repository.base` | Base class for MongoDB repositories |
64
+ | `MongoJSONEncoder` | `aws_python_helper.utils.json_encoder` | JSON encoder for MongoDB types |
65
+ | `mongo_json_dumps` | `aws_python_helper.utils.json_encoder` | Helper to serialize MongoDB types |
66
+ | `serialize_mongo_types` | `aws_python_helper.utils.serializer` | Recursively serialize MongoDB types |
67
+ | `UnauthorizedError` | `aws_python_helper.api.exceptions` | 401 authentication exception |
68
+ | `ForbiddenError` | `aws_python_helper.api.exceptions` | 403 authorization exception |
69
+
70
+ ## 📂 Project Structure
71
+
72
+ This framework follows a convention-based folder structure. Here's the recommended organization:
73
+
74
+ ```
75
+ your-project/
76
+ └── src/
77
+ ├── api/ # REST APIs
78
+ │ └── users/ # Resource folder (kebab-case)
79
+ │ ├── get.py # GET /users/123 -> UserGetAPI
80
+ │ ├── list.py # GET /users -> UserListAPI
81
+ │ ├── post.py # POST /users -> UserPostAPI
82
+ │ ├── put.py # PUT /users/123 -> UserPutAPI
83
+ │ └── delete.py # DELETE /users/123 -> UserDeleteAPI
84
+
85
+ ├── consumer/ # SQS Consumers (direct files)
86
+ │ ├── user_created.py # user-created -> UserCreatedConsumer
87
+ │ ├── title_indexed.py # title-indexed -> TitleIndexedConsumer
88
+ │ └── order_processed.py # order-processed -> OrderProcessedConsumer
89
+
90
+ ├── lambda/ # Standalone Lambdas (folders)
91
+ │ ├── generate-route/ # generate-route -> GenerateRouteLambda
92
+ │ │ └── main.py
93
+ │ ├── sync-carrier/ # sync-carrier -> SyncCarrierLambda
94
+ │ │ └── main.py
95
+ │ └── process-payment/ # process-payment -> ProcessPaymentLambda
96
+ │ └── main.py
97
+
98
+ ├── task/ # Fargate Tasks (folders)
99
+ │ ├── search-tax-by-town/ # search-tax-by-town -> SearchTaxByTownTask
100
+ │ │ ├── main.py # Entry point
101
+ │ │ └── task.py # Task class
102
+ │ └── process-data/ # process-data -> ProcessDataTask
103
+ │ ├── main.py
104
+ │ └── task.py
105
+
106
+ └── topic/ # SNS Publishers
107
+ └── order_created.py # OrderCreatedTopic
108
+ ```
109
+
110
+ ### Naming Conventions
111
+
112
+ The framework uses automatic class name detection based on your folder/file structure:
113
+
114
+ | Type | Handler Name | File Path | Class Name |
115
+ |------|--------------|-----------|------------|
116
+ | **API** | N/A | `src/api/users/list.py` | `UsersListAPI` |
117
+ | **Consumer** | `user-created` | `src/consumer/user_created.py` | `UserCreatedConsumer` |
118
+ | **Lambda** | `generate-route` | `src/lambda/generate-route/main.py` | `GenerateRouteLambda` |
119
+ | **Task** | `search-tax-by-town` | `src/task/search-tax-by-town/task.py` | `SearchTaxByTownTask` |
120
+
121
+ **Rules:**
122
+ - Handler names use **kebab-case** (e.g., `user-created`, `generate-route`)
123
+ - Consumer files use **snake_case** (e.g., `user_created.py`)
124
+ - Lambda folders use **kebab-case** (e.g., `generate-route/`)
125
+ - Task folders use **kebab-case** (e.g., `search-tax-by-town/`)
126
+ - Class names always use **PascalCase** with suffix (e.g., `UserCreatedConsumer`)
127
+
128
+ ## 📝 Basic Usage
129
+
130
+ ### Create an Endpoint
131
+
132
+ **1. Create your API class** in `src/api/constitutions/list.py`:
133
+
134
+ ```python
135
+ from aws_python_helper.api.base import API
136
+
137
+ class ConstitutionListAPI(API):
138
+ async def process(self):
139
+ # Direct access to MongoDB
140
+ constitutions = await self.db.constitution_db.constitutions.find().to_list(100)
141
+ self.set_body(constitutions)
142
+ ```
143
+
144
+ **2. The routing is automatic:**
145
+ - `GET /constitutions` → `src/api/constitutions/list.py`
146
+ - `GET /constitutions/123` → `src/api/constitutions/get.py`
147
+ - `POST /constitutions` → `src/api/constitutions/post.py`
148
+
149
+ **3. Configure the generic handler** (`src/handlers/api_handler.py`):
150
+
151
+ ```python
152
+ from aws_python_helper.api.handler import api_handler
153
+ handler = api_handler
154
+ ```
155
+
156
+ ### Create an SQS Consumer
157
+
158
+ **1. Create your consumer** in `src/consumer/title_indexed.py`:
159
+
160
+ ```python
161
+ from aws_python_helper.sqs.consumer_base import SQSConsumer
162
+
163
+ class TitleIndexedConsumer(SQSConsumer):
164
+ async def process_record(self, record):
165
+ body = self.extract_content_message(record)
166
+ # Your logic here
167
+ await self.db.constitution_db.titles.insert_one(body)
168
+ ```
169
+
170
+ **2. Configure the handler** in `src/handlers/sqs_handler.py`:
171
+
172
+ ```python
173
+ from aws_python_helper.sqs.handler import sqs_handler
174
+
175
+ # Create a handler for each consumer and export it
176
+ title_indexed_handler = sqs_handler('title-indexed')
177
+
178
+ __all__ = ['title_indexed_handler']
179
+ ```
180
+
181
+ ### Create a Standalone Lambda
182
+
183
+ Standalone lambdas are functions that can be invoked directly using the AWS SDK, without an HTTP endpoint. They're perfect for internal operations, integrations, and background processing tasks.
184
+
185
+ **Differences with APIs:**
186
+ - No API Gateway - invoked directly with AWS SDK
187
+ - No HTTP methods or routing
188
+ - Can be called from other lambdas, Step Functions, or any AWS service
189
+ - Perfect for internal microservices communication
190
+
191
+ **1. Create your lambda class** in `src/lambda/generate-route/main.py`:
192
+
193
+ ```python
194
+ from aws_python_helper.lambda_standalone.base import Lambda
195
+ from datetime import datetime
196
+
197
+ class GenerateRouteLambda(Lambda):
198
+ async def validate(self):
199
+ # Validate input data
200
+ if 'shipping_id' not in self.data:
201
+ raise ValueError("shipping_id is required")
202
+
203
+ if not isinstance(self.data['shipping_id'], str):
204
+ raise TypeError("shipping_id must be a string")
205
+
206
+ async def process(self):
207
+ # Your business logic here
208
+ shipping_id = self.data['shipping_id']
209
+
210
+ # Access to MongoDB
211
+ shipping = await self.db.deliveries.shippings.find_one(
212
+ {'_id': shipping_id}
213
+ )
214
+
215
+ if not shipping:
216
+ raise ValueError(f"Shipping {shipping_id} not found")
217
+
218
+ # Create route
219
+ route = {
220
+ 'shipping_id': shipping_id,
221
+ 'carrier_id': shipping.get('carrier_id'),
222
+ 'status': 'pending',
223
+ 'created_at': datetime.utcnow()
224
+ }
225
+
226
+ result = await self.db.deliveries.routes.insert_one(route)
227
+
228
+ self.logger.info(f"Route created: {result.inserted_id}")
229
+
230
+ # Return result
231
+ return {
232
+ 'route_id': str(result.inserted_id),
233
+ 'shipping_id': shipping_id
234
+ }
235
+ ```
236
+
237
+ **2. Configure the handler** in `src/handlers/lambda_handler.py`:
238
+
239
+ ```python
240
+ from aws_python_helper.lambda_standalone.handler import lambda_handler
241
+
242
+ # Create a handler for each lambda and export it
243
+ generate_route_handler = lambda_handler('generate-route')
244
+ sync_carrier_handler = lambda_handler('sync-carrier')
245
+ process_payment_handler = lambda_handler('process-payment')
246
+
247
+ __all__ = [
248
+ 'generate_route_handler',
249
+ 'sync_carrier_handler',
250
+ 'process_payment_handler'
251
+ ]
252
+ ```
253
+
254
+ **Note:** The handler name `'generate-route'` (kebab-case) will automatically look for:
255
+ - Folder: `src/lambda/generate-route/` (kebab-case)
256
+ - File: `main.py`
257
+ - Class: `GenerateRouteLambda`
258
+
259
+ **3. Invoke from another Lambda or API** using boto3:
260
+
261
+ ```python
262
+ import boto3
263
+ import json
264
+
265
+ lambda_client = boto3.client('lambda')
266
+
267
+ # Invoke synchronously (RequestResponse)
268
+ response = lambda_client.invoke(
269
+ FunctionName='GenerateRouteLambda',
270
+ InvocationType='RequestResponse',
271
+ Payload=json.dumps({
272
+ 'data': {
273
+ 'shipping_id': '507f1f77bcf86cd799439011'
274
+ }
275
+ })
276
+ )
277
+
278
+ result = json.loads(response['Payload'].read())
279
+ # {'success': True, 'data': {'route_id': '...', 'shipping_id': '...'}}
280
+
281
+ if result['success']:
282
+ print(f"Route created: {result['data']['route_id']}")
283
+ else:
284
+ print(f"Error: {result['error']}")
285
+ ```
286
+
287
+ **4. Invoke asynchronously** (fire and forget):
288
+
289
+ ```python
290
+ # Invoke asynchronously (Event)
291
+ lambda_client.invoke(
292
+ FunctionName='GenerateRouteLambda',
293
+ InvocationType='Event', # Asynchronous
294
+ Payload=json.dumps({
295
+ 'data': {
296
+ 'shipping_id': '507f1f77bcf86cd799439011'
297
+ }
298
+ })
299
+ )
300
+ # Returns immediately without waiting for the result
301
+ ```
302
+
303
+ **Naming Convention:**
304
+
305
+ | Lambda Name (kebab-case) | Folder | File | Class |
306
+ |--------------------------|--------|------|-------|
307
+ | `generate-route` | `src/lambda/generate-route/` | `main.py` | `GenerateRouteLambda` |
308
+ | `sync-carrier` | `src/lambda/sync-carrier/` | `main.py` | `SyncCarrierLambda` |
309
+ | `process-payment` | `src/lambda/process-payment/` | `main.py` | `ProcessPaymentLambda` |
310
+ | `send-notification` | `src/lambda/send-notification/` | `main.py` | `SendNotificationLambda` |
311
+
312
+ **Common Use Cases:**
313
+ - Internal microservices communication
314
+ - Background data processing
315
+ - Integration with external services
316
+ - Scheduled tasks (with EventBridge)
317
+ - Step Functions workflows
318
+ - Cross-service operations
319
+
320
+ ### Publish to SNS
321
+
322
+ **1. Create your topic** in `src/topic/title_indexed.py`:
323
+
324
+ ```python
325
+ from aws_python_helper.sns.publisher import SNSPublisher
326
+ import os
327
+
328
+ class TitleIndexedTopic(SNSPublisher):
329
+ def __init__(self):
330
+ super().__init__(
331
+ topic_arn=os.getenv('TITLE_INDEXED_SNS_TOPIC_ARN')
332
+ )
333
+
334
+ def build_message(self, constitution_id, title, event_type='title_indexed'):
335
+ return {
336
+ 'content': {
337
+ 'constitution_id': constitution_id,
338
+ 'title': title,
339
+ 'event_type': event_type
340
+ },
341
+ 'attributes': {
342
+ 'event_type': event_type # Used for SNS subscription filtering
343
+ }
344
+ }
345
+ ```
346
+
347
+ **2. Use the topic** from anywhere:
348
+
349
+ ```python
350
+ from src.topic.title_indexed import TitleIndexedTopic
351
+
352
+ # In a consumer, API or task
353
+ topic = TitleIndexedTopic()
354
+
355
+ # Publish a single message
356
+ await topic.publish(topic.build_message('123', 'My Constitution'))
357
+
358
+ # Publish multiple messages in batch
359
+ messages = [
360
+ topic.build_message('id1', 'Constitution A'),
361
+ topic.build_message('id2', 'Constitution B'),
362
+ ]
363
+ await topic.publish(messages)
364
+ ```
365
+
366
+ **Message format** — every message must have a `content` key:
367
+
368
+ ```python
369
+ {
370
+ 'content': {...}, # Required: message body (any dict)
371
+ 'attributes': {...}, # Optional: SNS message attributes for filtering
372
+ 'subject': 'Optional subject' # Optional: message subject
373
+ }
374
+ ```
375
+
376
+ ### Run a Fargate Task
377
+
378
+ **1. Create your task** in `src/task/search-tax-by-town/task.py`:
379
+
380
+ ```python
381
+ from aws_python_helper.fargate.task_base import FargateTask
382
+
383
+ class SearchTaxByTownTask(FargateTask):
384
+
385
+ async def execute(self):
386
+ town = self.require_env('TOWN')
387
+ self.logger.info(f"Processing town: {town}")
388
+
389
+ # Access to DB
390
+ docs = await self.db.smart_data.address.find({'town': town}).to_list()
391
+
392
+ # Your logic here
393
+ for doc in docs:
394
+ # Process document
395
+ pass
396
+ ```
397
+
398
+ **2. Create the entry point** in `src/task/search-tax-by-town/main.py`:
399
+
400
+ ```python
401
+ from aws_python_helper.fargate.handler import fargate_handler
402
+ import sys
403
+
404
+ if __name__ == '__main__':
405
+ exit_code = fargate_handler('search-tax-by-town')
406
+ sys.exit(exit_code)
407
+ ```
408
+
409
+ **3. Create the Dockerfile** in `src/task/search-tax-by-town/Dockerfile`:
410
+
411
+ ```dockerfile
412
+ FROM python:3.10.12-slim
413
+ WORKDIR /app
414
+
415
+ # Install dependencies
416
+ COPY requirements.txt /app/framework_requirements.txt
417
+ COPY src/task/search-tax-by-town/requirements.txt /app/task_requirements.txt
418
+ RUN pip install -r /app/framework_requirements.txt && \
419
+ pip install -r /app/task_requirements.txt
420
+
421
+ # Copy code
422
+ COPY aws_python_helper /app/aws_python_helper
423
+ COPY config.py /app/config.py
424
+ COPY task /app/task
425
+ COPY task/search-tax-by-town/main.py /app/main.py
426
+
427
+ ENV PYTHONUNBUFFERED=1
428
+ CMD ["python", "main.py"]
429
+ ```
430
+
431
+ **4. Invoke from Lambda**:
432
+
433
+ ```python
434
+ from aws_python_helper.fargate.executor import FargateExecutor
435
+
436
+ def handler(event, context):
437
+ executor = FargateExecutor()
438
+ task_arn = executor.run_task(
439
+ 'search-tax-by-town',
440
+ envs={'TOWN': 'Norwalk', 'ONLY_TAX': 'true'}
441
+ )
442
+ return {'taskArn': task_arn}
443
+ ```
444
+
445
+ ## 🗄️ Access to MongoDB
446
+
447
+ The framework provides flexible access to multiple databases:
448
+
449
+ ```python
450
+ class MyAPI(API):
451
+ async def process(self):
452
+ # Access to different databases on the same cluster
453
+ user = await self.db.users_db.users.find_one({'_id': user_id})
454
+
455
+ # Another database
456
+ await self.db.analytics_db.logs.insert_one({'action': 'view'})
457
+
458
+ # Multiple collections
459
+ titles = await self.db.constitution_db.titles.find().to_list(100)
460
+ articles = await self.db.constitution_db.articles.find().to_list(100)
461
+ ```
462
+
463
+ The pattern is always: `self.db.<database_name>.<collection_name>.<motor_operation>()`
464
+
465
+ ### External MongoDB Clusters
466
+
467
+ Connect to additional MongoDB clusters using `EXTERNAL_MONGODB_CONNECTIONS`:
468
+
469
+ ```bash
470
+ EXTERNAL_MONGODB_CONNECTIONS='[
471
+ {"name": "ClusterDockets", "connection_string": "mongodb+srv://cluster.mongodb.net"},
472
+ {"name": "ClusterAnalytics", "connection_string": "mongodb+srv://analytics.mongodb.net"}
473
+ ]'
474
+ ```
475
+
476
+ The credentials from `MONGO_DB_USER` / `MONGO_DB_PASSWORD` are automatically injected into the connection strings.
477
+
478
+ Access external clusters via `self.external_db`:
479
+
480
+ ```python
481
+ class AddressAPI(API):
482
+ async def process(self):
483
+ # Access external cluster: self.external_db.<ClusterName>.<database>.<collection>
484
+ addresses = await self.external_db.ClusterDockets.smart_data.addresses.find(
485
+ {'town': self.data['town']}
486
+ ).to_list(100)
487
+
488
+ self.set_body({'addresses': addresses})
489
+ ```
490
+
491
+ `self.external_db` is available in `API`, `SQSConsumer`, `Lambda`, and `FargateTask`.
492
+
493
+ ## 🗂️ Repository Pattern
494
+
495
+ The framework provides a `Repository` base class that eliminates repetitive boilerplate in data access layers. Each repository only declares what collection it uses, whether it belongs to an external cluster, and what indexes to create. The base class handles the MongoDB connection and index creation automatically.
496
+
497
+ ### Properties to override
498
+
499
+ | Property | Type | Default | Required |
500
+ |----------|------|---------|----------|
501
+ | `collection_name` | `str` | — | **Yes** |
502
+ | `database_name` | `str` | `"core"` | No |
503
+ | `is_external` | `bool` | `False` | No |
504
+ | `cluster_name` | `str` | `None` | Only if `is_external=True` |
505
+ | `indexes` | `list` | `[]` | No |
506
+
507
+ ### Index format
508
+
509
+ ```python
510
+ @property
511
+ def indexes(self):
512
+ return [
513
+ {"key": [("field", 1)]}, # simple ASC
514
+ {"key": [("field", -1)]}, # simple DESC
515
+ {"key": [("f1", 1), ("f2", -1)], "unique": True}, # compound + unique
516
+ {"key": [("expires_at", 1)], "expireAfterSeconds": 0}, # TTL index
517
+ ]
518
+ ```
519
+
520
+ Indexes are created automatically in the background on first collection access — no need to call any initialization method.
521
+
522
+ ### Repository on the main cluster (`database_name` defaults to `"core"`)
523
+
524
+ ```python
525
+ from aws_python_helper import Repository
526
+
527
+ class TownsRepository(Repository):
528
+
529
+ @property
530
+ def collection_name(self):
531
+ return "towns"
532
+
533
+ @property
534
+ def indexes(self):
535
+ return [
536
+ {"key": [("name", 1)]},
537
+ {"key": [("platform", 1)]},
538
+ ]
539
+
540
+ async def get_available(self, platforms):
541
+ return await self.collection.find(
542
+ {"platform": {"$in": platforms}},
543
+ {"name": 1, "platform": 1}
544
+ ).to_list(length=None)
545
+
546
+ async def find_by_name(self, name):
547
+ return await self.collection.find_one({"name": name})
548
+ ```
549
+
550
+ ### Repository on a different database (not `"core"`)
551
+
552
+ ```python
553
+ from aws_python_helper import Repository
554
+
555
+ class LandRecordsRepository(Repository):
556
+
557
+ @property
558
+ def database_name(self):
559
+ return "land_data"
560
+
561
+ @property
562
+ def collection_name(self):
563
+ return "records"
564
+
565
+ @property
566
+ def indexes(self):
567
+ return [
568
+ {"key": [("unique_id", 1)]},
569
+ {"key": [("owner", 1), ("town", 1)]},
570
+ ]
571
+
572
+ async def bulk_upsert(self, records):
573
+ from pymongo import UpdateOne
574
+ operations = [
575
+ UpdateOne({"unique_id": r["unique_id"]}, {"$set": r}, upsert=True)
576
+ for r in records
577
+ ]
578
+ result = await self.collection.bulk_write(operations)
579
+ return {"upserted": result.upserted_count, "modified": result.modified_count}
580
+ ```
581
+
582
+ ### Repository on an external cluster
583
+
584
+ ```python
585
+ from aws_python_helper import Repository
586
+
587
+ class AddressRepository(Repository):
588
+
589
+ @property
590
+ def database_name(self):
591
+ return "smart_data"
592
+
593
+ @property
594
+ def collection_name(self):
595
+ return "address"
596
+
597
+ @property
598
+ def is_external(self):
599
+ return True
600
+
601
+ @property
602
+ def cluster_name(self):
603
+ return "ClusterDockets" # Must match a name in EXTERNAL_MONGODB_CONNECTIONS
604
+
605
+ async def find_by_query(self, query, limit=None):
606
+ cursor = self.collection.find(query)
607
+ if limit:
608
+ cursor = cursor.limit(limit)
609
+ return await cursor.to_list(length=None)
610
+ ```
611
+
612
+ ### Instantiation — no `db` argument needed
613
+
614
+ ```python
615
+ class MyAPI(API):
616
+
617
+ @property
618
+ def towns_repository(self):
619
+ if not self._towns_repository:
620
+ self._towns_repository = TownsRepository() # no args!
621
+ return self._towns_repository
622
+
623
+ async def process(self):
624
+ towns = await self.towns_repository.get_available(["platform_a", "platform_b"])
625
+ self.set_body({"towns": towns})
626
+ ```
627
+
628
+ The repository connects itself using the already-initialized `MongoManager` singleton — the same one used by `self.db`. No need to pass `self.db` or any connection object.
629
+
630
+ ## 🔄 Routing Convention
631
+
632
+ The framework uses convention over configuration for the routing:
633
+
634
+ | Request | Loaded file |
635
+ |---------|----------------|
636
+ | `GET /users` | `api/users/list.py` |
637
+ | `GET /users/123` | `api/users/get.py` |
638
+ | `POST /users` | `api/users/post.py` |
639
+ | `PUT /users/123` | `api/users/put.py` |
640
+ | `DELETE /users/123` | `api/users/delete.py` |
641
+ | `GET /users/123/posts` | `api/users/posts/list.py` |
642
+ | `GET /users/123/posts/456` | `api/users/posts/get.py` |
643
+
644
+ **Logic:**
645
+ - The parts with **even indices** (0,2,4...) are **directories**
646
+ - The parts with **odd indices** (1,3,5...) are **path parameters**
647
+ - `GET` with **odd number of parts** → **list** method
648
+ - `GET` with **even number of parts** → **get** method
649
+ - Other methods use their name directly
650
+
651
+ ## 🧩 API Class Reference
652
+
653
+ All properties and methods available inside an `API` subclass:
654
+
655
+ ### Request Properties
656
+
657
+ | Property | Type | Description |
658
+ |----------|------|-------------|
659
+ | `self.data` | `dict` | Request body (POST/PUT) or query params (GET) |
660
+ | `self.headers` | `dict` | HTTP request headers |
661
+ | `self.path_parameters` | `dict` | URL path parameters (e.g. `/users/123` → `{'id': '123'}`) |
662
+ | `self.query_parameters` | `dict` | Query string parameters |
663
+ | `self.db` | `DatabaseProxy` | Access to main MongoDB cluster |
664
+ | `self.external_db` | `ExternalDatabaseProxy` | Access to external MongoDB clusters |
665
+ | `self.current_user` | `dict \| None` | Authenticated user document (requires `REQUIRE_AUTH=true`) |
666
+ | `self.is_authenticated` | `bool` | Whether the request is authenticated |
667
+ | `self.auth_data` | `dict \| None` | Full authentication data |
668
+
669
+ ### Response Methods
670
+
671
+ | Method | Description |
672
+ |--------|-------------|
673
+ | `self.set_code(code: int)` | Set HTTP response status code |
674
+ | `self.set_body(body: Any)` | Set response body (auto-serialized to JSON) |
675
+ | `self.set_header(key: str, value: str)` | Add a single response header |
676
+ | `self.set_headers(headers: dict)` | Set multiple response headers at once |
677
+
678
+ ### Methods to Override
679
+
680
+ | Method | Required | Description |
681
+ |--------|----------|-------------|
682
+ | `async validate()` | Optional | Validate request data, raise exceptions to reject |
683
+ | `async process()` | **Required** | Main business logic |
684
+
685
+ ```python
686
+ class UserGetAPI(API):
687
+ async def validate(self):
688
+ # Access path params: /users/123 → self.path_parameters = {'id': '123'}
689
+ if not self.path_parameters.get('id'):
690
+ raise ValueError("User ID is required")
691
+
692
+ async def process(self):
693
+ user_id = self.path_parameters['id']
694
+ user = await self.db.users_db.users.find_one({'_id': user_id})
695
+
696
+ if not user:
697
+ self.set_code(404)
698
+ self.set_body({'error': 'User not found'})
699
+ return
700
+
701
+ self.set_code(200)
702
+ self.set_body({'data': user})
703
+ self.set_header('X-Resource-Id', user_id)
704
+ ```
705
+
706
+ ## 🔐 Authentication
707
+
708
+ The framework includes a built-in token-based authentication middleware.
709
+
710
+ ### Configuration
711
+
712
+ ```bash
713
+ REQUIRE_AUTH=true # Enable authentication (default: false)
714
+ AUTH_DB_NAME=my_database # MongoDB database where tokens are stored
715
+ AUTH_BYPASS_TOKEN=secret123 # Master token to bypass auth (for internal use)
716
+ ```
717
+
718
+ ### Using the authenticated user
719
+
720
+ When `REQUIRE_AUTH=true`, every request must include a valid `Authorization: Bearer <token>` header. The authenticated user is available via `self.current_user`:
721
+
722
+ ```python
723
+ class OrderListAPI(API):
724
+ async def process(self):
725
+ # self.current_user contains the user document from MongoDB
726
+ user_id = self.current_user['_id']
727
+
728
+ orders = await self.db.orders_db.orders.find(
729
+ {'user_id': user_id}
730
+ ).to_list(100)
731
+
732
+ self.set_body({'data': orders})
733
+ ```
734
+
735
+ ### Auth exceptions
736
+
737
+ Use these exceptions in your `validate()` or `process()` methods:
738
+
739
+ ```python
740
+ from aws_python_helper.api.exceptions import UnauthorizedError, ForbiddenError
741
+
742
+ class AdminOnlyAPI(API):
743
+ async def validate(self):
744
+ if not self.is_authenticated:
745
+ raise UnauthorizedError("Authentication required") # Returns 401
746
+
747
+ if self.current_user.get('role') != 'admin':
748
+ raise ForbiddenError("Admin access required") # Returns 403
749
+ ```
750
+
751
+ ## 🎯 Complete Example
752
+
753
+ ```python
754
+ # src/api/constitutions/list.py
755
+ from aws_python_helper.api.base import API
756
+
757
+ class ConstitutionListAPI(API):
758
+ async def validate(self):
759
+ if 'limit' in self.data:
760
+ limit = int(self.data['limit'])
761
+ if limit > 1000:
762
+ raise ValueError("Limit cannot exceed 1000")
763
+
764
+ async def process(self):
765
+ # Build filters
766
+ filters = {}
767
+ if 'country' in self.data:
768
+ filters['country'] = self.data['country']
769
+
770
+ # Query MongoDB
771
+ limit = int(self.data.get('limit', 100))
772
+ results = await self.db.constitution_db.constitutions.find(
773
+ filters
774
+ ).limit(limit).to_list(limit)
775
+
776
+ # Count total
777
+ total = await self.db.constitution_db.constitutions.count_documents(filters)
778
+
779
+ # Register in analytics
780
+ await self.db.analytics_db.searches.insert_one({
781
+ 'filters': filters,
782
+ 'result_count': len(results)
783
+ })
784
+
785
+ # Response
786
+ self.set_body({
787
+ 'data': results,
788
+ 'total': total
789
+ })
790
+ self.set_header('X-Total-Count', str(total))
791
+ ```
792
+
793
+ ## 🔗 Integration Example: API + Standalone Lambda
794
+
795
+ Here's a complete example showing how an API can invoke a standalone lambda:
796
+
797
+ **Scenario:** An API endpoint that creates a shipping and then asynchronously generates its route using a standalone lambda.
798
+
799
+ **1. The API endpoint** (`src/api/shippings/post.py`):
800
+
801
+ ```python
802
+ from aws_python_helper.api.base import API
803
+ import boto3
804
+ import json
805
+
806
+ class ShippingPostAPI(API):
807
+ async def validate(self):
808
+ required_fields = ['customer_id', 'address', 'items']
809
+ for field in required_fields:
810
+ if field not in self.data:
811
+ raise ValueError(f"{field} is required")
812
+
813
+ async def process(self):
814
+ # Create shipping in database
815
+ shipping = {
816
+ 'customer_id': self.data['customer_id'],
817
+ 'address': self.data['address'],
818
+ 'items': self.data['items'],
819
+ 'status': 'pending',
820
+ 'route_pending': True
821
+ }
822
+
823
+ result = await self.db.deliveries.shippings.insert_one(shipping)
824
+ shipping_id = str(result.inserted_id)
825
+
826
+ # Invoke standalone lambda asynchronously to generate route
827
+ lambda_client = boto3.client('lambda')
828
+ lambda_client.invoke(
829
+ FunctionName='GenerateRouteLambda',
830
+ InvocationType='Event', # Asynchronous
831
+ Payload=json.dumps({
832
+ 'data': {'shipping_id': shipping_id}
833
+ })
834
+ )
835
+
836
+ self.set_code(201)
837
+ self.set_body({
838
+ 'shipping_id': shipping_id,
839
+ 'status': 'pending',
840
+ 'message': 'Shipping created, route generation in progress'
841
+ })
842
+ ```
843
+
844
+ **2. The standalone lambda** (`src/lambda/generate-route/main.py`):
845
+
846
+ ```python
847
+ from aws_python_helper.lambda_standalone.base import Lambda
848
+
849
+ class GenerateRouteLambda(Lambda):
850
+ async def validate(self):
851
+ if 'shipping_id' not in self.data:
852
+ raise ValueError("shipping_id is required")
853
+
854
+ async def process(self):
855
+ shipping_id = self.data['shipping_id']
856
+
857
+ # Get shipping details
858
+ shipping = await self.db.deliveries.shippings.find_one(
859
+ {'_id': shipping_id}
860
+ )
861
+
862
+ if not shipping:
863
+ raise ValueError(f"Shipping {shipping_id} not found")
864
+
865
+ # Generate optimal route
866
+ route = await self.calculate_optimal_route(shipping)
867
+
868
+ # Save route
869
+ route_result = await self.db.deliveries.routes.insert_one(route)
870
+
871
+ # Update shipping
872
+ await self.db.deliveries.shippings.update_one(
873
+ {'_id': shipping_id},
874
+ {'$set': {
875
+ 'route_id': route_result.inserted_id,
876
+ 'route_pending': False,
877
+ 'status': 'scheduled'
878
+ }}
879
+ )
880
+
881
+ return {
882
+ 'route_id': str(route_result.inserted_id),
883
+ 'shipping_id': shipping_id
884
+ }
885
+
886
+ async def calculate_optimal_route(self, shipping):
887
+ # Your route calculation logic here
888
+ return {
889
+ 'shipping_id': shipping['_id'],
890
+ 'carrier_id': shipping.get('carrier_id'),
891
+ 'estimated_duration': 60,
892
+ 'status': 'pending'
893
+ }
894
+ ```
895
+
896
+ **3. Configure handlers** (`src/handlers/lambda_handler.py`):
897
+
898
+ ```python
899
+ from aws_python_helper.lambda_standalone.handler import lambda_handler
900
+
901
+ generate_route_handler = lambda_handler('generate-route')
902
+
903
+ __all__ = ['generate_route_handler']
904
+ ```
905
+
906
+ **Benefits of this pattern:**
907
+ - API responds immediately (better UX)
908
+ - Route generation happens in the background
909
+ - Decoupled services (easier to maintain)
910
+ - Can retry lambda independently if it fails
911
+ - Scalable architecture
912
+
913
+ ## 🏗️ Architecture Overview
914
+
915
+ Typical flow for event-driven architectures using this framework:
916
+
917
+ ```
918
+ ┌──────────┐ ┌─────────────┐ ┌──────────────────────────────────────┐
919
+ │ Client │────▶│ API Gateway │────▶│ Lambda: api_handler │
920
+ └──────────┘ └─────────────┘ │ (src/api/resource/post.py) │
921
+ │ → validates, queries MongoDB, │
922
+ │ publishes to SNS │
923
+ └────────────────┬─────────────────────┘
924
+
925
+
926
+ ┌─────────────────┐
927
+ │ SNS Topic │
928
+ │ (fanout/filter) │
929
+ └────────┬────────┘
930
+ ┌───────────────┼───────────────┐
931
+ ▼ ▼ ▼
932
+ ┌────────────┐ ┌────────────┐ ┌────────────┐
933
+ │ SQS Queue │ │ SQS Queue │ │ SQS Queue │
934
+ │ Platform A│ │ Platform B│ │ Platform C│
935
+ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘
936
+ │ │ │
937
+ ▼ ▼ ▼
938
+ ┌──────────────────────────────────────────────┐
939
+ │ Lambda: sqs_handler │
940
+ │ (src/consumer/platform_consumer.py) │
941
+ │ → groups messages, acquires sessions, │
942
+ │ launches Fargate tasks │
943
+ └───────────────────┬──────────────────────────┘
944
+ │ FargateExecutor.run_task()
945
+
946
+ ┌──────────────────────────────────────────────┐
947
+ │ Fargate Task: fargate_handler │
948
+ │ (src/task/my-task/task.py) │
949
+ │ → scrapes/processes data, │
950
+ │ writes results to MongoDB │
951
+ └──────────────────────────────────────────────┘
952
+ ```
953
+
954
+ ## 🔐 Environment Variables
955
+
956
+ ### MongoDB Configuration
957
+
958
+ The framework supports two ways to configure MongoDB:
959
+
960
+ #### Option 1: Full Connection String
961
+
962
+ ```bash
963
+ # Full URI with embedded credentials
964
+ MONGODB_URI=mongodb+srv://user:password@cluster.mongodb.net/dbname?retryWrites=true&w=majority
965
+ # or
966
+ MONGO_DB_URI=mongodb+srv://user:password@cluster.mongodb.net/dbname
967
+ ```
968
+
969
+ #### Option 2: Separate Components (Recommended for Terraform)
970
+
971
+ ```bash
972
+ # Host without credentials
973
+ MONGO_DB_HOST=mongodb+srv://cluster.mongodb.net
974
+
975
+ # Credentials (more secure)
976
+ MONGO_DB_USER=admin
977
+ MONGO_DB_PASSWORD=my-secure-password
978
+
979
+ # Optional
980
+ MONGO_DB_NAME=my_database
981
+ MONGO_DB_OPTIONS=retryWrites=true&w=majority
982
+ ```
983
+
984
+ **Benefits of separate components:**
985
+ - ✅ Better security: credentials separate from host
986
+ - ✅ Easy integration with Terraform/AWS Secrets Manager
987
+ - ✅ Passwords with special characters are handled automatically
988
+ - ✅ More flexible for different environments
989
+
990
+ The framework automatically:
991
+ 1. URL-encodes the password (handles `@`, `:`, `/`, etc.)
992
+ 2. Builds the full URI
993
+ 3. Initializes the connection
994
+
995
+ #### Terraform Example
996
+
997
+ ```hcl
998
+ environment_variables = {
999
+ MONGO_DB_HOST = module.mongodb.connection_string
1000
+ MONGO_DB_USER = module.mongodb.database_user
1001
+ MONGO_DB_PASSWORD = module.mongodb.database_password
1002
+ }
1003
+ ```
1004
+
1005
+ ### All Environment Variables
1006
+
1007
+ | Variable | Required | Description |
1008
+ |----------|----------|-------------|
1009
+ | `MONGODB_URI` or `MONGO_DB_URI` | One of these or components below | Full MongoDB connection string |
1010
+ | `MONGO_DB_HOST` | Alt. to URI | MongoDB host (e.g. `mongodb+srv://cluster.net`) |
1011
+ | `MONGO_DB_USER` | Alt. to URI | MongoDB username |
1012
+ | `MONGO_DB_PASSWORD` | Alt. to URI | MongoDB password |
1013
+ | `MONGO_DB_NAME` | Optional | Default database name |
1014
+ | `MONGO_DB_OPTIONS` | Optional | Connection options (e.g. `retryWrites=true&w=majority`) |
1015
+ | `EXTERNAL_MONGODB_CONNECTIONS` | Optional | JSON array of external cluster configurations |
1016
+ | `REQUIRE_AUTH` | Optional | Enable authentication middleware (`true`/`false`) |
1017
+ | `AUTH_DB_NAME` | If `REQUIRE_AUTH=true` | MongoDB database for token validation |
1018
+ | `AUTH_BYPASS_TOKEN` | Optional | Master token to bypass authentication |
1019
+ | `ECS_CLUSTER` | Fargate only | ECS cluster name for `FargateExecutor` |
1020
+ | `ECS_SUBNETS` | Fargate only | Comma-separated subnet IDs for Fargate tasks |
1021
+ | `AWS_REGION` | Fargate/SNS/SQS | AWS region |
1022
+ | `AWS_ACCOUNT_ID` | SQS `get_queue_url` | AWS account ID |
1023
+ | `SERVICE_NAME` | SQS `get_queue_url` | Service name prefix for queue name |
1024
+ | `QUEUE_NAME` | SQS `get_queue_url` | Queue name segment |
1025
+ | `ENV` | SQS `get_queue_url` | Environment suffix (e.g. `prod`, `dev`) |
1026
+
1027
+ ## 📊 Advanced Features
1028
+
1029
+ ### SQS Consumer - Batch Mode
1030
+
1031
+ By default, consumers process messages one by one (`"single"` mode). Use `"batch"` mode when you need to group or bulk-process messages:
1032
+
1033
+ ```python
1034
+ from aws_python_helper.sqs.consumer_base import SQSConsumer
1035
+
1036
+ class OrderConsumer(SQSConsumer):
1037
+
1038
+ @property
1039
+ def processing_mode(self) -> str:
1040
+ return "batch"
1041
+
1042
+ async def process_batch(self, records):
1043
+ # Group records by some key before processing
1044
+ grouped = {}
1045
+ for record in records:
1046
+ message_id = record.get('messageId')
1047
+ body = self.extract_content_message(record)
1048
+ key = body.get('region', 'default')
1049
+ grouped.setdefault(key, []).append((message_id, body))
1050
+
1051
+ for region, messages in grouped.items():
1052
+ try:
1053
+ # Bulk operation for the whole group
1054
+ docs = [msg[1] for msg in messages]
1055
+ await self.db.orders_db.orders.insert_many(docs)
1056
+ except Exception as e:
1057
+ # Mark individual messages as failed
1058
+ for message_id, _ in messages:
1059
+ self.add_message_failed(message_id, str(e))
1060
+ ```
1061
+
1062
+ **Key methods in SQSConsumer:**
1063
+
1064
+ | Method / Property | Description |
1065
+ |-------------------|-------------|
1066
+ | `self.extract_content_message(record)` | Parse message body (handles SNS → SQS wrapping automatically) |
1067
+ | `self.parse_body(record)` | Alias for `extract_content_message` |
1068
+ | `self.add_message_failed(message_id, error)` | Mark a message for retry (batch mode) |
1069
+ | `self.get_queue_url()` | Get the SQS queue URL (uses `AWS_REGION`, `AWS_ACCOUNT_ID`, `SERVICE_NAME`, `QUEUE_NAME`, `ENV`) |
1070
+ | `self.db` | Access to main MongoDB cluster |
1071
+ | `self.external_db` | Access to external MongoDB clusters |
1072
+
1073
+ **Retry behavior:**
1074
+ - Messages marked with `add_message_failed()` are reported via `reportBatchItemFailures`
1075
+ - AWS SQS retries **only** the failed messages, not the whole batch
1076
+ - Successful messages in the same batch are not retried
1077
+
1078
+ ### SNS Publisher - Batch Publishing
1079
+
1080
+ ```python
1081
+ topic = TitleIndexedTopic()
1082
+
1083
+ # Publish multiple messages in a single call
1084
+ await topic.publish([
1085
+ {'content': {'id': 'id1', 'title': 'Title 1'}, 'attributes': {'type': 'created'}},
1086
+ {'content': {'id': 'id2', 'title': 'Title 2'}, 'attributes': {'type': 'updated'}},
1087
+ {'content': {'id': 'id3', 'title': 'Title 3'}}, # attributes are optional
1088
+ ])
1089
+ ```
1090
+
1091
+ ### SNS - Message Attributes for Filtering
1092
+
1093
+ Use `attributes` to filter which SQS subscriptions receive each message:
1094
+
1095
+ ```python
1096
+ class EventTopic(SNSPublisher):
1097
+ def __init__(self):
1098
+ super().__init__(topic_arn=os.getenv('EVENTS_SNS_TOPIC_ARN'))
1099
+
1100
+ def build_message(self, payload, event_type, priority='normal'):
1101
+ return {
1102
+ 'content': payload,
1103
+ 'attributes': {
1104
+ 'event_type': event_type, # SQS subscriptions can filter on this
1105
+ 'priority': priority
1106
+ }
1107
+ }
1108
+
1109
+ # Usage
1110
+ topic = EventTopic()
1111
+ await topic.publish(topic.build_message(
1112
+ payload={'order_id': '123', 'amount': 99.99},
1113
+ event_type='order_created',
1114
+ priority='high'
1115
+ ))
1116
+ ```
1117
+
1118
+ ### Fargate - Run multiple tasks
1119
+
1120
+ ```python
1121
+ executor = FargateExecutor()
1122
+ task_arns = executor.run_task_batch(
1123
+ 'search-tax-by-town',
1124
+ [
1125
+ {'TOWN': 'Norwalk'},
1126
+ {'TOWN': 'Stamford'},
1127
+ {'TOWN': 'Bridgeport'}
1128
+ ]
1129
+ )
1130
+ ```
1131
+
1132
+ ### Fargate - Check task status
1133
+
1134
+ ```python
1135
+ executor = FargateExecutor()
1136
+ task_arn = executor.run_task('my-task', {'PARAM': 'value'})
1137
+
1138
+ # Check task status
1139
+ status = executor.get_task_status(task_arn)
1140
+ print(f"Status: {status['status']}")
1141
+ print(f"Started at: {status['started_at']}")
1142
+ ```
1143
+
1144
+ ### JSON Utilities for MongoDB Types
1145
+
1146
+ When returning MongoDB documents in API responses or exporting data, use the built-in serializers to handle `ObjectId`, `datetime`, `Decimal128`, and other BSON types:
1147
+
1148
+ ```python
1149
+ import json
1150
+ from aws_python_helper.utils.json_encoder import MongoJSONEncoder, mongo_json_dumps
1151
+ from aws_python_helper.utils.serializer import serialize_mongo_types
1152
+
1153
+ # Use as json.dumps cls parameter
1154
+ json_str = json.dumps(my_mongo_doc, cls=MongoJSONEncoder)
1155
+
1156
+ # Helper function
1157
+ json_str = mongo_json_dumps(my_mongo_doc)
1158
+
1159
+ # Convert a document in-place (dict → JSON-serializable dict)
1160
+ clean_doc = serialize_mongo_types(my_mongo_doc)
1161
+ ```
1162
+
1163
+ Types automatically converted:
1164
+
1165
+ | MongoDB Type | Converts to |
1166
+ |-------------|-------------|
1167
+ | `ObjectId` | `str` |
1168
+ | `datetime` | ISO 8601 string |
1169
+ | `date` | ISO 8601 string |
1170
+ | `Decimal128` | `float` |
1171
+ | `Decimal` | `float` |
1172
+ | `Binary` | base64 `str` |
1173
+ | `UUID` | `str` |
1174
+ | `bytes` | base64 `str` |
1175
+ | `set` | `list` |
1176
+
1177
+ **Common use case** — exporting query results to JSON files:
1178
+
1179
+ ```python
1180
+ from aws_python_helper.utils.json_encoder import MongoJSONEncoder
1181
+
1182
+ class ExportResultsAPI(API):
1183
+ async def process(self):
1184
+ records = await self.db.orders_db.orders.find({}).to_list(1000)
1185
+
1186
+ # Write to file with MongoJSONEncoder
1187
+ with open('/tmp/export.json', 'w') as f:
1188
+ json.dump(records, f, cls=MongoJSONEncoder, ensure_ascii=False, indent=2)
1189
+ ```
1190
+
1191
+ ## 🤝 Contributing
1192
+
1193
+ If you find bugs or want to add features, please create a PR!
1194
+
1195
+ ## 📄 License
1196
+
1197
+ MIT