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.
- aws_python_helper-0.31.0/PKG-INFO +1197 -0
- aws_python_helper-0.31.0/README.md +1177 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/__init__.py +4 -0
- aws_python_helper-0.31.0/aws_python_helper/repository/__init__.py +3 -0
- aws_python_helper-0.31.0/aws_python_helper/repository/base.py +188 -0
- aws_python_helper-0.31.0/aws_python_helper.egg-info/PKG-INFO +1197 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper.egg-info/SOURCES.txt +2 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/pyproject.toml +1 -1
- aws_python_helper-0.30.0/PKG-INFO +0 -712
- aws_python_helper-0.30.0/README.md +0 -692
- aws_python_helper-0.30.0/aws_python_helper.egg-info/PKG-INFO +0 -712
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/api/__init__.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/api/auth_middleware.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/api/auth_validators.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/api/base.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/api/dispatcher.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/api/exceptions.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/api/fetcher.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/api/handler.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/database/__init__.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/database/database_proxy.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/database/external_database_proxy.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/database/external_mongo_manager.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/database/mongo_manager.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/fargate/__init__.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/fargate/executor.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/fargate/fetcher.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/fargate/handler.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/fargate/task_base.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/lambda_standalone/__init__.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/lambda_standalone/base.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/lambda_standalone/fetcher.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/lambda_standalone/handler.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/sns/__init__.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/sns/publisher.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/sqs/__init__.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/sqs/consumer_base.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/sqs/fetcher.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/sqs/handler.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/utils/__init__.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/utils/json_encoder.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/utils/response.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper/utils/serializer.py +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper.egg-info/dependency_links.txt +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper.egg-info/requires.txt +0 -0
- {aws_python_helper-0.30.0 → aws_python_helper-0.31.0}/aws_python_helper.egg-info/top_level.txt +0 -0
- {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
|