aws-python-helper 1.0.2__tar.gz → 1.1.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 (56) hide show
  1. {aws_python_helper-1.0.2/aws_python_helper.egg-info → aws_python_helper-1.1.0}/PKG-INFO +32 -12
  2. aws_python_helper-1.0.2/PKG-INFO → aws_python_helper-1.1.0/README.md +29 -31
  3. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/api/auth_validators.py +109 -0
  4. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/api/dispatcher.py +5 -5
  5. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/api/fetcher.py +10 -1
  6. aws_python_helper-1.1.0/aws_python_helper/model_index_sync/base.py +191 -0
  7. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/repository/base.py +68 -0
  8. aws_python_helper-1.0.2/README.md → aws_python_helper-1.1.0/aws_python_helper.egg-info/PKG-INFO +51 -11
  9. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper.egg-info/requires.txt +3 -0
  10. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/pyproject.toml +5 -1
  11. aws_python_helper-1.0.2/aws_python_helper/model_index_sync/base.py +0 -114
  12. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/__init__.py +0 -0
  13. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/api/__init__.py +0 -0
  14. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/api/auth_middleware.py +0 -0
  15. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/api/base.py +0 -0
  16. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/api/exceptions.py +0 -0
  17. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/api/handler.py +0 -0
  18. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/context/__init__.py +0 -0
  19. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/context/session.py +0 -0
  20. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/context/state_validator.py +0 -0
  21. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/database/__init__.py +0 -0
  22. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/database/database_proxy.py +0 -0
  23. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/database/external_database_proxy.py +0 -0
  24. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/database/external_mongo_manager.py +0 -0
  25. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/database/mongo_manager.py +0 -0
  26. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/fargate/__init__.py +0 -0
  27. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/fargate/executor.py +0 -0
  28. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/fargate/fetcher.py +0 -0
  29. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/fargate/handler.py +0 -0
  30. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/fargate/task_base.py +0 -0
  31. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/invoker/__init__.py +0 -0
  32. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/invoker/api_client.py +0 -0
  33. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/invoker/exceptions.py +0 -0
  34. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/invoker/lambda_invoker.py +0 -0
  35. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/lambda_standalone/__init__.py +0 -0
  36. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/lambda_standalone/base.py +0 -0
  37. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/lambda_standalone/fetcher.py +0 -0
  38. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/lambda_standalone/handler.py +0 -0
  39. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/model_index_sync/__init__.py +0 -0
  40. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/model_query/__init__.py +0 -0
  41. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/model_query/base.py +0 -0
  42. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/repository/__init__.py +0 -0
  43. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/sns/__init__.py +0 -0
  44. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/sns/publisher.py +0 -0
  45. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/sqs/__init__.py +0 -0
  46. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/sqs/consumer_base.py +0 -0
  47. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/sqs/fetcher.py +0 -0
  48. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/sqs/handler.py +0 -0
  49. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/utils/__init__.py +0 -0
  50. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/utils/json_encoder.py +0 -0
  51. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/utils/response.py +0 -0
  52. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper/utils/serializer.py +0 -0
  53. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper.egg-info/SOURCES.txt +0 -0
  54. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper.egg-info/dependency_links.txt +0 -0
  55. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/aws_python_helper.egg-info/top_level.txt +0 -0
  56. {aws_python_helper-1.0.2 → aws_python_helper-1.1.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-python-helper
3
- Version: 1.0.2
3
+ Version: 1.1.0
4
4
  Summary: AWS Python Helper Framework
5
5
  Author-email: Fabian Claros <neufabiae@gmail.com>
6
6
  License-Expression: MIT
@@ -17,6 +17,8 @@ Requires-Dist: motor==3.3.2
17
17
  Requires-Dist: pymongo==4.6.1
18
18
  Requires-Dist: pydantic>=2.0
19
19
  Requires-Dist: httpx>=0.27.0
20
+ Provides-Extra: jwt
21
+ Requires-Dist: PyJWT[crypto]>=2.9.0; extra == "jwt"
20
22
 
21
23
  # AWS Python Framework
22
24
 
@@ -39,7 +41,7 @@ Mini-framework to create REST APIs, SQS Consumers, SNS Publishers, Fargate Tasks
39
41
  - **LambdaInvoker**: Invoke other Lambda functions (sync or async) with built-in error handling
40
42
  - **ApiClient**: HTTP client for inter-service communication — resolves service URLs and auth token from environment variables automatically
41
43
  - **ModelQueryLambda**: Base class for MongoDB query-proxy Lambdas — declare an `allowed_collections` whitelist and the framework handles `find` and `aggregate` operations generically
42
- - **ModelIndexSyncLambda**: Base class for index-synchronization Lambdas — declare your `repositories` and the framework creates every declared index across the `core` database and all active state databases, idempotently, in a single run (run once per deploy)
44
+ - **ModelIndexSyncLambda**: Base class for index-synchronization Lambdas — declare your `repositories` and the framework creates the declared indexes **and prunes orphans** across the `core` database and all active state databases, idempotently and in parallel, in a single run (run once per deploy)
43
45
  - **JSON utilities**: Automatic serialization of MongoDB types
44
46
  - **Type hints**: Modern Python with type annotations
45
47
  - **Async/await**: Full support for asynchronous operations
@@ -68,7 +70,7 @@ All available classes and functions:
68
70
  | `fargate_handler` | `aws_python_helper.fargate.handler` | Entry point handler for Fargate |
69
71
  | `Repository` | `aws_python_helper.repository.base` | Base class for MongoDB repositories |
70
72
  | `ModelQueryLambda` | `aws_python_helper` | Generic MongoDB query-proxy Lambda base class |
71
- | `ModelIndexSyncLambda` | `aws_python_helper` | Index-sync Lambda base class (creates indexes across `core` + all state DBs) |
73
+ | `ModelIndexSyncLambda` | `aws_python_helper` | Index-sync Lambda base class (creates declared indexes + prunes orphans across `core` + all state DBs, in parallel) |
72
74
  | `LambdaInvoker` | `aws_python_helper` | Invoke Lambda functions (sync/async) with error handling |
73
75
  | `ApiClient` | `aws_python_helper` | HTTP client for inter-service communication |
74
76
  | `LambdaInvocationError` | `aws_python_helper` | Raised when boto3 fails to invoke a Lambda |
@@ -1360,10 +1362,16 @@ Always pass `self.session.to_dict()` when calling from an API or Lambda to ensur
1360
1362
 
1361
1363
  ## 🧱 ModelIndexSyncLambda
1362
1364
 
1363
- `ModelIndexSyncLambda` is a ready-to-use base class for **creating all of a service's MongoDB indexes deterministically**, in one run. Because index creation is no longer triggered at request time (see the [Repository Pattern](#️-repository-pattern) note), this Lambda is the single source of truth for materializing indexes — typically invoked **once per deploy**.
1365
+ `ModelIndexSyncLambda` is a ready-to-use base class for **synchronizing all of a service's MongoDB indexes deterministically**, in one run. Because index creation is no longer triggered at request time (see the [Repository Pattern](#️-repository-pattern) note), this Lambda is the single source of truth for materializing indexes — typically invoked **once per deploy**.
1364
1366
 
1365
1367
  **Why:** in short-lived Lambda/API runtimes, creating indexes lazily on first access often never finishes (the function responds and freezes). This Lambda creates every declared index up front, `await`-ing each one, idempotently — and across **every** active state database, not just the one of the current request.
1366
1368
 
1369
+ It does a **two-way sync** per collection: it creates the declared indexes **and prunes orphans** — any index present in MongoDB that is no longer declared in the repository's `indexes` is dropped (the default `_id_` index and special text/geo/hashed indexes are always preserved).
1370
+
1371
+ > ⚠️ **Orphan pruning drops indexes not declared in code.** Indexes created out-of-band (by a DBA, Atlas, or another tool) that aren't in any repository's `indexes` will be removed on every run. Declare every index you want to keep.
1372
+
1373
+ **Concurrency & fault tolerance:** each `(collection, database)` unit is synced **in parallel** (bounded by `max_concurrency`, default 10); index work *within* a unit stays sequential. A failure in one unit is **captured and isolated** — it never aborts the others, so a single broken collection doesn't stop the rest of the sync. The top-level response is always `success: true`; per-unit outcomes are reported in `results[].success`.
1374
+
1367
1375
  ### Creating an index-sync Lambda
1368
1376
 
1369
1377
  Create `src/lambda/model-index-sync/main.py` and declare which repositories to sync:
@@ -1409,14 +1417,20 @@ This Lambda sets `requires_state = False`: it is not tied to a single state and
1409
1417
 
1410
1418
  ### Response format
1411
1419
 
1420
+ The top-level `success` is **always `true`** (a failing collection no longer aborts the run). Inspect `data.summary` for the OK/failed counts and `data.results[].success` for each unit; failed units include an `error` field.
1421
+
1412
1422
  ```json
1413
1423
  {
1414
1424
  "success": true,
1415
- "data": [
1416
- {"repository": "ParcelsRepository", "database": "connecticut", "collection": "parcels", "indexes": ["parcel_id_1", "..."]},
1417
- {"repository": "ParcelsRepository", "database": "new_jersey", "collection": "parcels", "indexes": ["parcel_id_1", "..."]},
1418
- {"repository": "UsersRepository", "database": "core", "collection": "users", "indexes": ["email_1"]}
1419
- ]
1425
+ "data": {
1426
+ "summary": { "total": 4, "succeeded": 3, "failed": 1 },
1427
+ "results": [
1428
+ {"repository": "ParcelsRepository", "database": "connecticut", "collection": "parcels", "indexes": ["parcel_id_1", "..."], "orphan_indexes_dropped": ["old_field_1"], "success": true},
1429
+ {"repository": "ParcelsRepository", "database": "new_jersey", "collection": "parcels", "indexes": ["parcel_id_1", "..."], "orphan_indexes_dropped": [], "success": true},
1430
+ {"repository": "UsersRepository", "database": "core", "collection": "users", "indexes": ["email_1"], "orphan_indexes_dropped": [], "success": true},
1431
+ {"repository": "TokensRepository", "database": "core", "collection": "tokens", "indexes": [], "orphan_indexes_dropped": [], "success": false, "error": "..."}
1432
+ ]
1433
+ }
1420
1434
  }
1421
1435
  ```
1422
1436
 
@@ -1427,11 +1441,14 @@ This Lambda sets `requires_state = False`: it is not tied to a single state and
1427
1441
  | `repositories` | `[]` | **Required.** List of `Repository` subclasses (the classes, not instances) to sync. Empty raises a validation error |
1428
1442
  | `states_database` | `"core"` | Database holding the states collection |
1429
1443
  | `states_collection` | `"states"` | Collection listing the states (filtered by `is_active = true`, field `name`) |
1444
+ | `max_concurrency` | `10` | Max `(collection, database)` units synced in parallel. `0` (or negative) = unlimited |
1430
1445
  | `requires_state` | `False` | Inherited override — the Lambda runs across all states, no session required |
1431
1446
 
1432
1447
  ### Running it once per deploy (Terraform)
1433
1448
 
1434
- Since deploys are applied manually with Terraform, attach an `aws_lambda_invocation` that runs **on every `apply`**, right after the function code is updated. `timestamp()` changes on every plan, so it always re-runs (creation is idempotent and also picks up any newly-activated state). The `postcondition` fails the `apply` if the sync reports `success = false` (the framework handler returns HTTP 200 with `success = false` on internal errors, so this check is required to surface failures):
1449
+ Since deploys are applied manually with Terraform, attach an `aws_lambda_invocation` that runs **on every `apply`**, right after the function code is updated. `timestamp()` changes on every plan, so it always re-runs (creation is idempotent and also picks up any newly-activated state).
1450
+
1451
+ > **Detecting failures in the deploy.** The top-level `success` is now **always `true`** — a single failed collection no longer aborts the run, so checking `success` alone won't catch partial failures. If you want the `apply` to fail when any collection failed, point the `postcondition` at `data.summary.failed` instead (shown below). If you'd rather the deploy never fail on index errors and only review them afterward (in the response/logs), simply omit the `lifecycle` block.
1435
1452
 
1436
1453
  ```hcl
1437
1454
  module "lambda" {
@@ -1449,10 +1466,13 @@ resource "aws_lambda_invocation" "sync_on_deploy" {
1449
1466
 
1450
1467
  depends_on = [module.lambda] # run after the code is updated
1451
1468
 
1469
+ # Optional: fail the apply if any collection failed to sync.
1470
+ # Omit this whole lifecycle block if you prefer the deploy to never fail on
1471
+ # index errors (review data.results[].success / logs instead).
1452
1472
  lifecycle {
1453
1473
  postcondition {
1454
- condition = try(jsondecode(self.result).success, false) == true
1455
- error_message = "Index sync failed during apply: ${self.result}"
1474
+ condition = try(jsondecode(self.result).data.summary.failed, 1) == 0
1475
+ error_message = "Index sync had failures during apply: ${self.result}"
1456
1476
  }
1457
1477
  }
1458
1478
  }
@@ -1,23 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: aws-python-helper
3
- Version: 1.0.2
4
- Summary: AWS Python Helper Framework
5
- Author-email: Fabian Claros <neufabiae@gmail.com>
6
- License-Expression: 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: Operating System :: OS Independent
14
- Requires-Python: >=3.9
15
- Description-Content-Type: text/markdown
16
- Requires-Dist: motor==3.3.2
17
- Requires-Dist: pymongo==4.6.1
18
- Requires-Dist: pydantic>=2.0
19
- Requires-Dist: httpx>=0.27.0
20
-
21
1
  # AWS Python Framework
22
2
 
23
3
  Mini-framework to create REST APIs, SQS Consumers, SNS Publishers, Fargate Tasks, and Standalone Lambdas with Python in AWS Lambda.
@@ -39,7 +19,7 @@ Mini-framework to create REST APIs, SQS Consumers, SNS Publishers, Fargate Tasks
39
19
  - **LambdaInvoker**: Invoke other Lambda functions (sync or async) with built-in error handling
40
20
  - **ApiClient**: HTTP client for inter-service communication — resolves service URLs and auth token from environment variables automatically
41
21
  - **ModelQueryLambda**: Base class for MongoDB query-proxy Lambdas — declare an `allowed_collections` whitelist and the framework handles `find` and `aggregate` operations generically
42
- - **ModelIndexSyncLambda**: Base class for index-synchronization Lambdas — declare your `repositories` and the framework creates every declared index across the `core` database and all active state databases, idempotently, in a single run (run once per deploy)
22
+ - **ModelIndexSyncLambda**: Base class for index-synchronization Lambdas — declare your `repositories` and the framework creates the declared indexes **and prunes orphans** across the `core` database and all active state databases, idempotently and in parallel, in a single run (run once per deploy)
43
23
  - **JSON utilities**: Automatic serialization of MongoDB types
44
24
  - **Type hints**: Modern Python with type annotations
45
25
  - **Async/await**: Full support for asynchronous operations
@@ -68,7 +48,7 @@ All available classes and functions:
68
48
  | `fargate_handler` | `aws_python_helper.fargate.handler` | Entry point handler for Fargate |
69
49
  | `Repository` | `aws_python_helper.repository.base` | Base class for MongoDB repositories |
70
50
  | `ModelQueryLambda` | `aws_python_helper` | Generic MongoDB query-proxy Lambda base class |
71
- | `ModelIndexSyncLambda` | `aws_python_helper` | Index-sync Lambda base class (creates indexes across `core` + all state DBs) |
51
+ | `ModelIndexSyncLambda` | `aws_python_helper` | Index-sync Lambda base class (creates declared indexes + prunes orphans across `core` + all state DBs, in parallel) |
72
52
  | `LambdaInvoker` | `aws_python_helper` | Invoke Lambda functions (sync/async) with error handling |
73
53
  | `ApiClient` | `aws_python_helper` | HTTP client for inter-service communication |
74
54
  | `LambdaInvocationError` | `aws_python_helper` | Raised when boto3 fails to invoke a Lambda |
@@ -1360,10 +1340,16 @@ Always pass `self.session.to_dict()` when calling from an API or Lambda to ensur
1360
1340
 
1361
1341
  ## 🧱 ModelIndexSyncLambda
1362
1342
 
1363
- `ModelIndexSyncLambda` is a ready-to-use base class for **creating all of a service's MongoDB indexes deterministically**, in one run. Because index creation is no longer triggered at request time (see the [Repository Pattern](#️-repository-pattern) note), this Lambda is the single source of truth for materializing indexes — typically invoked **once per deploy**.
1343
+ `ModelIndexSyncLambda` is a ready-to-use base class for **synchronizing all of a service's MongoDB indexes deterministically**, in one run. Because index creation is no longer triggered at request time (see the [Repository Pattern](#️-repository-pattern) note), this Lambda is the single source of truth for materializing indexes — typically invoked **once per deploy**.
1364
1344
 
1365
1345
  **Why:** in short-lived Lambda/API runtimes, creating indexes lazily on first access often never finishes (the function responds and freezes). This Lambda creates every declared index up front, `await`-ing each one, idempotently — and across **every** active state database, not just the one of the current request.
1366
1346
 
1347
+ It does a **two-way sync** per collection: it creates the declared indexes **and prunes orphans** — any index present in MongoDB that is no longer declared in the repository's `indexes` is dropped (the default `_id_` index and special text/geo/hashed indexes are always preserved).
1348
+
1349
+ > ⚠️ **Orphan pruning drops indexes not declared in code.** Indexes created out-of-band (by a DBA, Atlas, or another tool) that aren't in any repository's `indexes` will be removed on every run. Declare every index you want to keep.
1350
+
1351
+ **Concurrency & fault tolerance:** each `(collection, database)` unit is synced **in parallel** (bounded by `max_concurrency`, default 10); index work *within* a unit stays sequential. A failure in one unit is **captured and isolated** — it never aborts the others, so a single broken collection doesn't stop the rest of the sync. The top-level response is always `success: true`; per-unit outcomes are reported in `results[].success`.
1352
+
1367
1353
  ### Creating an index-sync Lambda
1368
1354
 
1369
1355
  Create `src/lambda/model-index-sync/main.py` and declare which repositories to sync:
@@ -1409,14 +1395,20 @@ This Lambda sets `requires_state = False`: it is not tied to a single state and
1409
1395
 
1410
1396
  ### Response format
1411
1397
 
1398
+ The top-level `success` is **always `true`** (a failing collection no longer aborts the run). Inspect `data.summary` for the OK/failed counts and `data.results[].success` for each unit; failed units include an `error` field.
1399
+
1412
1400
  ```json
1413
1401
  {
1414
1402
  "success": true,
1415
- "data": [
1416
- {"repository": "ParcelsRepository", "database": "connecticut", "collection": "parcels", "indexes": ["parcel_id_1", "..."]},
1417
- {"repository": "ParcelsRepository", "database": "new_jersey", "collection": "parcels", "indexes": ["parcel_id_1", "..."]},
1418
- {"repository": "UsersRepository", "database": "core", "collection": "users", "indexes": ["email_1"]}
1419
- ]
1403
+ "data": {
1404
+ "summary": { "total": 4, "succeeded": 3, "failed": 1 },
1405
+ "results": [
1406
+ {"repository": "ParcelsRepository", "database": "connecticut", "collection": "parcels", "indexes": ["parcel_id_1", "..."], "orphan_indexes_dropped": ["old_field_1"], "success": true},
1407
+ {"repository": "ParcelsRepository", "database": "new_jersey", "collection": "parcels", "indexes": ["parcel_id_1", "..."], "orphan_indexes_dropped": [], "success": true},
1408
+ {"repository": "UsersRepository", "database": "core", "collection": "users", "indexes": ["email_1"], "orphan_indexes_dropped": [], "success": true},
1409
+ {"repository": "TokensRepository", "database": "core", "collection": "tokens", "indexes": [], "orphan_indexes_dropped": [], "success": false, "error": "..."}
1410
+ ]
1411
+ }
1420
1412
  }
1421
1413
  ```
1422
1414
 
@@ -1427,11 +1419,14 @@ This Lambda sets `requires_state = False`: it is not tied to a single state and
1427
1419
  | `repositories` | `[]` | **Required.** List of `Repository` subclasses (the classes, not instances) to sync. Empty raises a validation error |
1428
1420
  | `states_database` | `"core"` | Database holding the states collection |
1429
1421
  | `states_collection` | `"states"` | Collection listing the states (filtered by `is_active = true`, field `name`) |
1422
+ | `max_concurrency` | `10` | Max `(collection, database)` units synced in parallel. `0` (or negative) = unlimited |
1430
1423
  | `requires_state` | `False` | Inherited override — the Lambda runs across all states, no session required |
1431
1424
 
1432
1425
  ### Running it once per deploy (Terraform)
1433
1426
 
1434
- Since deploys are applied manually with Terraform, attach an `aws_lambda_invocation` that runs **on every `apply`**, right after the function code is updated. `timestamp()` changes on every plan, so it always re-runs (creation is idempotent and also picks up any newly-activated state). The `postcondition` fails the `apply` if the sync reports `success = false` (the framework handler returns HTTP 200 with `success = false` on internal errors, so this check is required to surface failures):
1427
+ Since deploys are applied manually with Terraform, attach an `aws_lambda_invocation` that runs **on every `apply`**, right after the function code is updated. `timestamp()` changes on every plan, so it always re-runs (creation is idempotent and also picks up any newly-activated state).
1428
+
1429
+ > **Detecting failures in the deploy.** The top-level `success` is now **always `true`** — a single failed collection no longer aborts the run, so checking `success` alone won't catch partial failures. If you want the `apply` to fail when any collection failed, point the `postcondition` at `data.summary.failed` instead (shown below). If you'd rather the deploy never fail on index errors and only review them afterward (in the response/logs), simply omit the `lifecycle` block.
1435
1430
 
1436
1431
  ```hcl
1437
1432
  module "lambda" {
@@ -1449,10 +1444,13 @@ resource "aws_lambda_invocation" "sync_on_deploy" {
1449
1444
 
1450
1445
  depends_on = [module.lambda] # run after the code is updated
1451
1446
 
1447
+ # Optional: fail the apply if any collection failed to sync.
1448
+ # Omit this whole lifecycle block if you prefer the deploy to never fail on
1449
+ # index errors (review data.results[].success / logs instead).
1452
1450
  lifecycle {
1453
1451
  postcondition {
1454
- condition = try(jsondecode(self.result).success, false) == true
1455
- error_message = "Index sync failed during apply: ${self.result}"
1452
+ condition = try(jsondecode(self.result).data.summary.failed, 1) == 0
1453
+ error_message = "Index sync had failures during apply: ${self.result}"
1456
1454
  }
1457
1455
  }
1458
1456
  }
@@ -141,3 +141,112 @@ class TokenValidator(AuthValidator):
141
141
  'token_data': token_doc,
142
142
  'is_bypass': False
143
143
  }
144
+
145
+
146
+ class JWTValidator(AuthValidator):
147
+ """
148
+ Validates RS256 JWTs issued by constitution-core.
149
+
150
+ Stateless: the signature and the claims are enough, so no database is
151
+ touched. That is what lets any microservice authenticate a request without
152
+ calling the issuer.
153
+
154
+ Environment:
155
+ JWT_PUBLIC_KEY: RSA public key in PEM, raw or base64-encoded.
156
+ JWT_ISSUER: expected `iss`. Defaults to 'constitution-core'.
157
+ JWT_AUDIENCE: expected `aud`. Only verified when set.
158
+ AUTH_BYPASS_TOKEN: still honoured, same as TokenValidator.
159
+ """
160
+
161
+ _public_key_cache: Optional[str] = None
162
+
163
+ @classmethod
164
+ def _public_key(cls) -> str:
165
+ """The configured public key, decoded once per container."""
166
+ if cls._public_key_cache:
167
+ return cls._public_key_cache
168
+
169
+ raw = os.getenv('JWT_PUBLIC_KEY')
170
+ if not raw:
171
+ raise ValueError(
172
+ "JWT_PUBLIC_KEY environment variable not set. "
173
+ "Required when AUTH_STRATEGY=jwt."
174
+ )
175
+
176
+ key = raw.strip()
177
+ if not key.startswith('-----BEGIN'):
178
+ # PEMs are multi-line, so they travel base64-encoded in env vars.
179
+ import base64
180
+ key = base64.b64decode(key).decode('utf-8')
181
+
182
+ cls._public_key_cache = key
183
+ return key
184
+
185
+ async def validate_token(self, token: str) -> Dict[str, Any]:
186
+ bypass_token = os.getenv('AUTH_BYPASS_TOKEN')
187
+ if bypass_token and token == bypass_token:
188
+ logger.info("Bypass token used - skipping JWT validation")
189
+ return {
190
+ 'user_id': 'bypass',
191
+ 'user': {
192
+ 'email': 'bypass@system',
193
+ 'role': 'admin',
194
+ 'name': 'Bypass User',
195
+ '_id': 'bypass'
196
+ },
197
+ 'is_bypass': True,
198
+ 'token_data': None
199
+ }
200
+
201
+ try:
202
+ import jwt
203
+ except ImportError as exc:
204
+ raise RuntimeError(
205
+ "PyJWT is required for AUTH_STRATEGY=jwt. Install aws-python-helper[jwt]."
206
+ ) from exc
207
+
208
+ audience = os.getenv('JWT_AUDIENCE')
209
+ try:
210
+ claims = jwt.decode(
211
+ token,
212
+ self._public_key(),
213
+ algorithms=['RS256'],
214
+ issuer=os.getenv('JWT_ISSUER', 'constitution-core'),
215
+ audience=audience,
216
+ options={'verify_aud': bool(audience)},
217
+ )
218
+ except jwt.ExpiredSignatureError:
219
+ logger.warning("JWT expired")
220
+ raise UnauthorizedError("Token has expired")
221
+ except jwt.InvalidTokenError as exc:
222
+ logger.warning("JWT rejected: %s", exc)
223
+ raise UnauthorizedError("Invalid token")
224
+
225
+ if not claims.get('sub'):
226
+ raise UnauthorizedError("Invalid token")
227
+
228
+ # Same shape TokenValidator returns, so nothing downstream changes.
229
+ return {
230
+ 'user_id': str(claims['sub']),
231
+ 'user': {
232
+ '_id': claims['sub'],
233
+ 'email': claims.get('email'),
234
+ 'name': claims.get('name', ''),
235
+ 'role': claims.get('role', 'user'),
236
+ **(claims.get('extra') or {}),
237
+ },
238
+ 'token_data': claims,
239
+ 'is_bypass': False,
240
+ }
241
+
242
+
243
+ def get_auth_validator() -> AuthValidator:
244
+ """The validator this service is configured to use.
245
+
246
+ AUTH_STRATEGY=jwt switches to stateless validation. Anything else keeps the
247
+ database lookup, so a service only migrates when its environment says so.
248
+ """
249
+ strategy = (os.getenv('AUTH_STRATEGY') or 'db').strip().lower()
250
+ if strategy == 'jwt':
251
+ return JWTValidator()
252
+ return TokenValidator()
@@ -11,7 +11,7 @@ from .fetcher import Fetcher
11
11
  from .base import API
12
12
  from .exceptions import UnauthorizedError, ForbiddenError, AuthenticationError
13
13
  from .auth_middleware import AuthMiddleware
14
- from .auth_validators import TokenValidator
14
+ from .auth_validators import get_auth_validator
15
15
  from ..context.session import get_session
16
16
  from ..context.state_validator import StateValidator, InvalidStateError
17
17
 
@@ -240,8 +240,8 @@ class Dispatcher:
240
240
  """
241
241
  Execute authentication middleware
242
242
 
243
- Uses TokenValidator to validate tokens against AUTH_BYPASS_TOKEN
244
- or MongoDB database.
243
+ The validator is chosen by AUTH_STRATEGY: stateless JWT, or the
244
+ database lookup used so far.
245
245
 
246
246
  Args:
247
247
  api: API instance to inject authentication data into
@@ -249,8 +249,8 @@ class Dispatcher:
249
249
  Raises:
250
250
  UnauthorizedError: If authentication fails
251
251
  """
252
- # Use unified token validator
253
- validator = TokenValidator()
252
+ # Strategy comes from configuration, per service
253
+ validator = get_auth_validator()
254
254
 
255
255
  # Create middleware and authenticate
256
256
  middleware = AuthMiddleware(validator)
@@ -91,7 +91,7 @@ class Fetcher:
91
91
 
92
92
  def _looks_like_id(self, part: str) -> bool:
93
93
  """
94
- Check if a URL part looks like an ID (numeric or UUID format)
94
+ Check if a URL part looks like an ID (numeric, ObjectId or UUID format)
95
95
 
96
96
  Args:
97
97
  part: URL part to check
@@ -105,6 +105,15 @@ class Fetcher:
105
105
  # Check if it's numeric
106
106
  if part.isdigit():
107
107
  return True
108
+
109
+ # ObjectId de MongoDB: 24 caracteres hexadecimales. Sin esto, un
110
+ # PATCH /users/<oid> busca un directorio con el id como nombre.
111
+ if len(part) == 24:
112
+ try:
113
+ int(part, 16)
114
+ return True
115
+ except ValueError:
116
+ pass
108
117
 
109
118
  # Check if it's a UUID format (8-4-4-4-12 hex digits)
110
119
  if len(part) == 36 and part.count('-') == 4:
@@ -0,0 +1,191 @@
1
+ """
2
+ ModelIndexSyncLambda - Synchronizes MongoDB indexes for a service's repositories.
3
+
4
+ Creates (idempotently) every index declared in the service's repositories, in
5
+ the correct databases:
6
+ - Repositories with a fixed database_key (e.g. 'core') → that database.
7
+ - State-scoped repositories (database_key is None) → every active state
8
+ database, read from core.states (is_active=True). A state-scoped collection
9
+ (e.g. 'parcels') therefore gets its indexes created in 'connecticut',
10
+ 'new_jersey', etc.
11
+
12
+ It also prunes orphan indexes: any index present in MongoDB but no longer
13
+ declared in a repository's `indexes` is dropped (see Repository.prune_orphan_indexes).
14
+
15
+ Designed to run once per deploy (and on demand), guaranteeing index creation
16
+ without the per-request overhead of creating indexes at runtime.
17
+
18
+ Each (collection, database) unit is synced concurrently (bounded by
19
+ `max_concurrency`); index creation WITHIN a unit stays sequential. A failure in
20
+ one unit is captured and never aborts the others, so a single broken collection
21
+ does not stop the rest of the sync — the failure is reported instead.
22
+
23
+ Usage — subclass and declare the repositories to sync:
24
+
25
+ import aws_python_helper
26
+ from repositories.parcels import ParcelsRepository
27
+ from repositories.users import UsersRepository
28
+
29
+ class PropertiesModelIndexSyncLambda(aws_python_helper.ModelIndexSyncLambda):
30
+ @property
31
+ def repositories(self) -> list:
32
+ return [ParcelsRepository, UsersRepository]
33
+
34
+ This Lambda does not require a session state (requires_state = False); it
35
+ operates across all active states.
36
+
37
+ Response (via Lambda base run()) — always success=True at the top level; per-unit
38
+ outcome is reported in results[].success:
39
+ {"success": True, "data": {
40
+ "summary": {"total": 3, "succeeded": 2, "failed": 1},
41
+ "results": [
42
+ {"repository": "ParcelsRepository", "database": "connecticut",
43
+ "collection": "parcels", "indexes": ["parcel_id_1", ...],
44
+ "orphan_indexes_dropped": ["old_field_1"], "success": True},
45
+ {"repository": "UsersRepository", "database": "core",
46
+ "collection": "users", "indexes": ["email_1"],
47
+ "orphan_indexes_dropped": [], "success": True},
48
+ {"repository": "TokensRepository", "database": "core",
49
+ "collection": "tokens", "indexes": [], "orphan_indexes_dropped": [],
50
+ "success": False, "error": "..."},
51
+ ]
52
+ }}
53
+ """
54
+
55
+ import asyncio
56
+ from typing import Any, List, Type
57
+
58
+ from ..lambda_standalone.base import Lambda
59
+ from ..repository.base import Repository
60
+ from ..database.mongo_manager import MongoManager
61
+
62
+
63
+ class ModelIndexSyncLambda(Lambda):
64
+ """
65
+ Base class for index-synchronization Lambdas.
66
+
67
+ Subclass and override `repositories` to declare which repositories to sync.
68
+ Everything else (resolving core vs state-scoped, reading active states and
69
+ creating the indexes) is handled automatically.
70
+ """
71
+
72
+ @property
73
+ def requires_state(self) -> bool:
74
+ # Operates across all states; not tied to a single one.
75
+ return False
76
+
77
+ @property
78
+ def repositories(self) -> List[Type[Repository]]:
79
+ """
80
+ Repositories whose indexes should be synchronized.
81
+
82
+ Override returning a list of Repository subclasses (the classes, not
83
+ instances). Declare them explicitly rather than auto-discovering, so the
84
+ synced set is intentional and nothing is silently missed or added.
85
+ """
86
+ return []
87
+
88
+ @property
89
+ def states_database(self) -> str:
90
+ """Database holding the states collection. Default: 'core'."""
91
+ return "core"
92
+
93
+ @property
94
+ def states_collection(self) -> str:
95
+ """Collection listing the states. Default: 'states'."""
96
+ return "states"
97
+
98
+ @property
99
+ def max_concurrency(self) -> int:
100
+ """
101
+ Max number of (collection, database) index syncs running in parallel.
102
+ 0 (or negative) means unlimited. Default: 10.
103
+ """
104
+ return 10
105
+
106
+ async def validate(self):
107
+ if not self.repositories:
108
+ raise ValueError(
109
+ "repositories is not configured on this ModelIndexSyncLambda"
110
+ )
111
+
112
+ async def _active_states(self) -> List[str]:
113
+ """Names of active states — i.e. the state-scoped database names."""
114
+ db = MongoManager.get_database(self.states_database)
115
+ cursor = db[self.states_collection].find(
116
+ {"is_active": True}, {"name": 1, "_id": 0}
117
+ )
118
+ return [doc["name"] async for doc in cursor if doc.get("name")]
119
+
120
+ async def _sync_target(self, repo_cls: Type[Repository], db_name: str) -> dict:
121
+ """
122
+ Sync one collection in one database: create declared indexes and drop
123
+ orphan ones.
124
+
125
+ Captures its own errors so a single failure never aborts the rest of the
126
+ sync — a failed unit returns success=False with the error message, and
127
+ any indexes created before the failure are still reported.
128
+ """
129
+ result = {
130
+ "repository": repo_cls.__name__,
131
+ "database": db_name,
132
+ "collection": None,
133
+ "indexes": [],
134
+ "orphan_indexes_dropped": [],
135
+ "success": True,
136
+ }
137
+ try:
138
+ repo = repo_cls()
139
+ result["collection"] = repo.collection_name
140
+ result["indexes"] = await repo.ensure_indexes(database_name=db_name)
141
+ result["orphan_indexes_dropped"] = await repo.prune_orphan_indexes(
142
+ database_name=db_name, dry_run=False
143
+ )
144
+ except Exception as e:
145
+ self.logger.error(
146
+ f"Index sync failed for {repo_cls.__name__} on '{db_name}': {e}"
147
+ )
148
+ result["success"] = False
149
+ result["error"] = str(e)
150
+ return result
151
+
152
+ async def process(self) -> Any:
153
+ active_states = await self._active_states()
154
+
155
+ # Plan all independent (repo_cls, db_name) work units.
156
+ targets: List[tuple] = []
157
+ for repo_cls in self.repositories:
158
+ repo = repo_cls()
159
+ # database_key is None -> state-scoped (collection repeats per state db).
160
+ is_state_scoped = repo.database_key is None and not repo.is_external
161
+ target_databases = active_states if is_state_scoped else [repo.database_name]
162
+ for db_name in target_databases:
163
+ targets.append((repo_cls, db_name))
164
+
165
+ # Each (collection, database) runs concurrently; index creation WITHIN
166
+ # each one stays sequential (via await). _sync_target captures its own
167
+ # errors, so one failed unit never aborts the others.
168
+ limit = self.max_concurrency
169
+ if limit and limit > 0:
170
+ semaphore = asyncio.Semaphore(limit)
171
+
172
+ async def run(repo_cls, db_name):
173
+ async with semaphore:
174
+ return await self._sync_target(repo_cls, db_name)
175
+ else:
176
+ async def run(repo_cls, db_name):
177
+ return await self._sync_target(repo_cls, db_name)
178
+
179
+ results = list(await asyncio.gather(
180
+ *(run(repo_cls, db_name) for repo_cls, db_name in targets)
181
+ ))
182
+
183
+ failed = [r for r in results if not r["success"]]
184
+ return {
185
+ "summary": {
186
+ "total": len(results),
187
+ "succeeded": len(results) - len(failed),
188
+ "failed": len(failed),
189
+ },
190
+ "results": results,
191
+ }
@@ -255,3 +255,71 @@ class Repository(ABC):
255
255
 
256
256
  self._indexes_created[key] = True
257
257
  return created
258
+
259
+ async def prune_orphan_indexes(
260
+ self, database_name: Optional[str] = None, dry_run: bool = True
261
+ ) -> List[str]:
262
+ """
263
+ Detects and (optionally) drops indexes that exist in MongoDB but are NOT
264
+ declared in the `indexes` property — i.e. orphan indexes left behind
265
+ after an index was removed from the code.
266
+
267
+ Matching is done by index key spec (fields + direction), never by name,
268
+ since index names are auto-generated by MongoDB. The default '_id_' index
269
+ is always preserved, and special indexes (text/geo/hashed, whose key spec
270
+ MongoDB rewrites internally) are skipped to avoid false positives.
271
+
272
+ Args:
273
+ database_name: Target database. Defaults to the resolved
274
+ database_name (database_key or current session state).
275
+ dry_run: If True (default), only reports orphans without dropping.
276
+ If False, drops each orphan index.
277
+
278
+ Returns:
279
+ List of orphan index names (dropped when dry_run=False).
280
+ """
281
+ db_name = database_name or self.database_name
282
+
283
+ if self.is_external:
284
+ if not self.cluster_name:
285
+ raise ValueError(
286
+ f"{self.__class__.__name__}: 'cluster_name' is required when is_external=True"
287
+ )
288
+ db = ExternalMongoManager.get_database(self.cluster_name, db_name)
289
+ else:
290
+ db = MongoManager.get_database(db_name)
291
+ collection = db[self.collection_name]
292
+
293
+ declared = {
294
+ tuple(tuple(field) for field in index_def["key"])
295
+ for index_def in self.indexes
296
+ if index_def.get("key")
297
+ }
298
+
299
+ orphans: List[str] = []
300
+ async for existing in collection.list_indexes():
301
+ name = existing.get("name")
302
+ if name == "_id_": # the default _id index can never be dropped
303
+ continue
304
+
305
+ key_spec = list(existing.get("key", {}).items())
306
+ # Skip special indexes (text/geo/hashed): MongoDB rewrites their key
307
+ # spec ("text"/2dsphere/"hashed", _fts/_ftsx), so a key-based
308
+ # comparison would wrongly flag them as orphans.
309
+ if any(isinstance(direction, str) for _, direction in key_spec) \
310
+ or any(field in ("_fts", "_ftsx") for field, _ in key_spec):
311
+ continue
312
+
313
+ if tuple(key_spec) not in declared:
314
+ orphans.append(name)
315
+ if not dry_run:
316
+ await collection.drop_index(name)
317
+ self.logger.info(
318
+ f"Dropped orphan index '{name}' on '{db_name}.{self.collection_name}'"
319
+ )
320
+ else:
321
+ self.logger.info(
322
+ f"[dry-run] orphan index '{name}' on '{db_name}.{self.collection_name}'"
323
+ )
324
+
325
+ return orphans
@@ -1,3 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: aws-python-helper
3
+ Version: 1.1.0
4
+ Summary: AWS Python Helper Framework
5
+ Author-email: Fabian Claros <neufabiae@gmail.com>
6
+ License-Expression: 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: Operating System :: OS Independent
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: motor==3.3.2
17
+ Requires-Dist: pymongo==4.6.1
18
+ Requires-Dist: pydantic>=2.0
19
+ Requires-Dist: httpx>=0.27.0
20
+ Provides-Extra: jwt
21
+ Requires-Dist: PyJWT[crypto]>=2.9.0; extra == "jwt"
22
+
1
23
  # AWS Python Framework
2
24
 
3
25
  Mini-framework to create REST APIs, SQS Consumers, SNS Publishers, Fargate Tasks, and Standalone Lambdas with Python in AWS Lambda.
@@ -19,7 +41,7 @@ Mini-framework to create REST APIs, SQS Consumers, SNS Publishers, Fargate Tasks
19
41
  - **LambdaInvoker**: Invoke other Lambda functions (sync or async) with built-in error handling
20
42
  - **ApiClient**: HTTP client for inter-service communication — resolves service URLs and auth token from environment variables automatically
21
43
  - **ModelQueryLambda**: Base class for MongoDB query-proxy Lambdas — declare an `allowed_collections` whitelist and the framework handles `find` and `aggregate` operations generically
22
- - **ModelIndexSyncLambda**: Base class for index-synchronization Lambdas — declare your `repositories` and the framework creates every declared index across the `core` database and all active state databases, idempotently, in a single run (run once per deploy)
44
+ - **ModelIndexSyncLambda**: Base class for index-synchronization Lambdas — declare your `repositories` and the framework creates the declared indexes **and prunes orphans** across the `core` database and all active state databases, idempotently and in parallel, in a single run (run once per deploy)
23
45
  - **JSON utilities**: Automatic serialization of MongoDB types
24
46
  - **Type hints**: Modern Python with type annotations
25
47
  - **Async/await**: Full support for asynchronous operations
@@ -48,7 +70,7 @@ All available classes and functions:
48
70
  | `fargate_handler` | `aws_python_helper.fargate.handler` | Entry point handler for Fargate |
49
71
  | `Repository` | `aws_python_helper.repository.base` | Base class for MongoDB repositories |
50
72
  | `ModelQueryLambda` | `aws_python_helper` | Generic MongoDB query-proxy Lambda base class |
51
- | `ModelIndexSyncLambda` | `aws_python_helper` | Index-sync Lambda base class (creates indexes across `core` + all state DBs) |
73
+ | `ModelIndexSyncLambda` | `aws_python_helper` | Index-sync Lambda base class (creates declared indexes + prunes orphans across `core` + all state DBs, in parallel) |
52
74
  | `LambdaInvoker` | `aws_python_helper` | Invoke Lambda functions (sync/async) with error handling |
53
75
  | `ApiClient` | `aws_python_helper` | HTTP client for inter-service communication |
54
76
  | `LambdaInvocationError` | `aws_python_helper` | Raised when boto3 fails to invoke a Lambda |
@@ -1340,10 +1362,16 @@ Always pass `self.session.to_dict()` when calling from an API or Lambda to ensur
1340
1362
 
1341
1363
  ## 🧱 ModelIndexSyncLambda
1342
1364
 
1343
- `ModelIndexSyncLambda` is a ready-to-use base class for **creating all of a service's MongoDB indexes deterministically**, in one run. Because index creation is no longer triggered at request time (see the [Repository Pattern](#️-repository-pattern) note), this Lambda is the single source of truth for materializing indexes — typically invoked **once per deploy**.
1365
+ `ModelIndexSyncLambda` is a ready-to-use base class for **synchronizing all of a service's MongoDB indexes deterministically**, in one run. Because index creation is no longer triggered at request time (see the [Repository Pattern](#️-repository-pattern) note), this Lambda is the single source of truth for materializing indexes — typically invoked **once per deploy**.
1344
1366
 
1345
1367
  **Why:** in short-lived Lambda/API runtimes, creating indexes lazily on first access often never finishes (the function responds and freezes). This Lambda creates every declared index up front, `await`-ing each one, idempotently — and across **every** active state database, not just the one of the current request.
1346
1368
 
1369
+ It does a **two-way sync** per collection: it creates the declared indexes **and prunes orphans** — any index present in MongoDB that is no longer declared in the repository's `indexes` is dropped (the default `_id_` index and special text/geo/hashed indexes are always preserved).
1370
+
1371
+ > ⚠️ **Orphan pruning drops indexes not declared in code.** Indexes created out-of-band (by a DBA, Atlas, or another tool) that aren't in any repository's `indexes` will be removed on every run. Declare every index you want to keep.
1372
+
1373
+ **Concurrency & fault tolerance:** each `(collection, database)` unit is synced **in parallel** (bounded by `max_concurrency`, default 10); index work *within* a unit stays sequential. A failure in one unit is **captured and isolated** — it never aborts the others, so a single broken collection doesn't stop the rest of the sync. The top-level response is always `success: true`; per-unit outcomes are reported in `results[].success`.
1374
+
1347
1375
  ### Creating an index-sync Lambda
1348
1376
 
1349
1377
  Create `src/lambda/model-index-sync/main.py` and declare which repositories to sync:
@@ -1389,14 +1417,20 @@ This Lambda sets `requires_state = False`: it is not tied to a single state and
1389
1417
 
1390
1418
  ### Response format
1391
1419
 
1420
+ The top-level `success` is **always `true`** (a failing collection no longer aborts the run). Inspect `data.summary` for the OK/failed counts and `data.results[].success` for each unit; failed units include an `error` field.
1421
+
1392
1422
  ```json
1393
1423
  {
1394
1424
  "success": true,
1395
- "data": [
1396
- {"repository": "ParcelsRepository", "database": "connecticut", "collection": "parcels", "indexes": ["parcel_id_1", "..."]},
1397
- {"repository": "ParcelsRepository", "database": "new_jersey", "collection": "parcels", "indexes": ["parcel_id_1", "..."]},
1398
- {"repository": "UsersRepository", "database": "core", "collection": "users", "indexes": ["email_1"]}
1399
- ]
1425
+ "data": {
1426
+ "summary": { "total": 4, "succeeded": 3, "failed": 1 },
1427
+ "results": [
1428
+ {"repository": "ParcelsRepository", "database": "connecticut", "collection": "parcels", "indexes": ["parcel_id_1", "..."], "orphan_indexes_dropped": ["old_field_1"], "success": true},
1429
+ {"repository": "ParcelsRepository", "database": "new_jersey", "collection": "parcels", "indexes": ["parcel_id_1", "..."], "orphan_indexes_dropped": [], "success": true},
1430
+ {"repository": "UsersRepository", "database": "core", "collection": "users", "indexes": ["email_1"], "orphan_indexes_dropped": [], "success": true},
1431
+ {"repository": "TokensRepository", "database": "core", "collection": "tokens", "indexes": [], "orphan_indexes_dropped": [], "success": false, "error": "..."}
1432
+ ]
1433
+ }
1400
1434
  }
1401
1435
  ```
1402
1436
 
@@ -1407,11 +1441,14 @@ This Lambda sets `requires_state = False`: it is not tied to a single state and
1407
1441
  | `repositories` | `[]` | **Required.** List of `Repository` subclasses (the classes, not instances) to sync. Empty raises a validation error |
1408
1442
  | `states_database` | `"core"` | Database holding the states collection |
1409
1443
  | `states_collection` | `"states"` | Collection listing the states (filtered by `is_active = true`, field `name`) |
1444
+ | `max_concurrency` | `10` | Max `(collection, database)` units synced in parallel. `0` (or negative) = unlimited |
1410
1445
  | `requires_state` | `False` | Inherited override — the Lambda runs across all states, no session required |
1411
1446
 
1412
1447
  ### Running it once per deploy (Terraform)
1413
1448
 
1414
- Since deploys are applied manually with Terraform, attach an `aws_lambda_invocation` that runs **on every `apply`**, right after the function code is updated. `timestamp()` changes on every plan, so it always re-runs (creation is idempotent and also picks up any newly-activated state). The `postcondition` fails the `apply` if the sync reports `success = false` (the framework handler returns HTTP 200 with `success = false` on internal errors, so this check is required to surface failures):
1449
+ Since deploys are applied manually with Terraform, attach an `aws_lambda_invocation` that runs **on every `apply`**, right after the function code is updated. `timestamp()` changes on every plan, so it always re-runs (creation is idempotent and also picks up any newly-activated state).
1450
+
1451
+ > **Detecting failures in the deploy.** The top-level `success` is now **always `true`** — a single failed collection no longer aborts the run, so checking `success` alone won't catch partial failures. If you want the `apply` to fail when any collection failed, point the `postcondition` at `data.summary.failed` instead (shown below). If you'd rather the deploy never fail on index errors and only review them afterward (in the response/logs), simply omit the `lifecycle` block.
1415
1452
 
1416
1453
  ```hcl
1417
1454
  module "lambda" {
@@ -1429,10 +1466,13 @@ resource "aws_lambda_invocation" "sync_on_deploy" {
1429
1466
 
1430
1467
  depends_on = [module.lambda] # run after the code is updated
1431
1468
 
1469
+ # Optional: fail the apply if any collection failed to sync.
1470
+ # Omit this whole lifecycle block if you prefer the deploy to never fail on
1471
+ # index errors (review data.results[].success / logs instead).
1432
1472
  lifecycle {
1433
1473
  postcondition {
1434
- condition = try(jsondecode(self.result).success, false) == true
1435
- error_message = "Index sync failed during apply: ${self.result}"
1474
+ condition = try(jsondecode(self.result).data.summary.failed, 1) == 0
1475
+ error_message = "Index sync had failures during apply: ${self.result}"
1436
1476
  }
1437
1477
  }
1438
1478
  }
@@ -2,3 +2,6 @@ motor==3.3.2
2
2
  pymongo==4.6.1
3
3
  pydantic>=2.0
4
4
  httpx>=0.27.0
5
+
6
+ [jwt]
7
+ PyJWT[crypto]>=2.9.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aws-python-helper"
7
- version = "1.0.2"
7
+ version = "1.1.0"
8
8
  description = "AWS Python Helper Framework"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -31,6 +31,10 @@ keywords = [
31
31
  "aws", "python", "framework", "helper", "mongodb", "sqs", "sns", "fargate", "lambda"
32
32
  ]
33
33
 
34
+ # Only needed by services running AUTH_STRATEGY=jwt.
35
+ [project.optional-dependencies]
36
+ jwt = ["PyJWT[crypto]>=2.9.0"]
37
+
34
38
  [project.urls]
35
39
  "Homepage" = "https://github.com/fabiae/aws-python-framework"
36
40
  "Source Code" = "https://github.com/fabiae/aws-python-framework"
@@ -1,114 +0,0 @@
1
- """
2
- ModelIndexSyncLambda - Synchronizes MongoDB indexes for a service's repositories.
3
-
4
- Creates (idempotently) every index declared in the service's repositories, in
5
- the correct databases:
6
- - Repositories with a fixed database_key (e.g. 'core') → that database.
7
- - State-scoped repositories (database_key is None) → every active state
8
- database, read from core.states (is_active=True). A state-scoped collection
9
- (e.g. 'parcels') therefore gets its indexes created in 'connecticut',
10
- 'new_jersey', etc.
11
-
12
- Designed to run once per deploy (and on demand), guaranteeing index creation
13
- without the per-request overhead of creating indexes at runtime.
14
-
15
- Usage — subclass and declare the repositories to sync:
16
-
17
- import aws_python_helper
18
- from repositories.parcels import ParcelsRepository
19
- from repositories.users import UsersRepository
20
-
21
- class PropertiesModelIndexSyncLambda(aws_python_helper.ModelIndexSyncLambda):
22
- @property
23
- def repositories(self) -> list:
24
- return [ParcelsRepository, UsersRepository]
25
-
26
- This Lambda does not require a session state (requires_state = False); it
27
- operates across all active states.
28
-
29
- Response (via Lambda base run()):
30
- {"success": True, "data": [
31
- {"repository": "ParcelsRepository", "database": "connecticut",
32
- "collection": "parcels", "indexes": ["parcel_id_1", ...]},
33
- {"repository": "UsersRepository", "database": "core",
34
- "collection": "users", "indexes": ["email_1"]},
35
- ...
36
- ]}
37
- """
38
-
39
- from typing import Any, List, Type
40
-
41
- from ..lambda_standalone.base import Lambda
42
- from ..repository.base import Repository
43
- from ..database.mongo_manager import MongoManager
44
-
45
-
46
- class ModelIndexSyncLambda(Lambda):
47
- """
48
- Base class for index-synchronization Lambdas.
49
-
50
- Subclass and override `repositories` to declare which repositories to sync.
51
- Everything else (resolving core vs state-scoped, reading active states and
52
- creating the indexes) is handled automatically.
53
- """
54
-
55
- @property
56
- def requires_state(self) -> bool:
57
- # Operates across all states; not tied to a single one.
58
- return False
59
-
60
- @property
61
- def repositories(self) -> List[Type[Repository]]:
62
- """
63
- Repositories whose indexes should be synchronized.
64
-
65
- Override returning a list of Repository subclasses (the classes, not
66
- instances). Declare them explicitly rather than auto-discovering, so the
67
- synced set is intentional and nothing is silently missed or added.
68
- """
69
- return []
70
-
71
- @property
72
- def states_database(self) -> str:
73
- """Database holding the states collection. Default: 'core'."""
74
- return "core"
75
-
76
- @property
77
- def states_collection(self) -> str:
78
- """Collection listing the states. Default: 'states'."""
79
- return "states"
80
-
81
- async def validate(self):
82
- if not self.repositories:
83
- raise ValueError(
84
- "repositories is not configured on this ModelIndexSyncLambda"
85
- )
86
-
87
- async def _active_states(self) -> List[str]:
88
- """Names of active states — i.e. the state-scoped database names."""
89
- db = MongoManager.get_database(self.states_database)
90
- cursor = db[self.states_collection].find(
91
- {"is_active": True}, {"name": 1, "_id": 0}
92
- )
93
- return [doc["name"] async for doc in cursor if doc.get("name")]
94
-
95
- async def process(self) -> Any:
96
- active_states = await self._active_states()
97
- report: List[dict] = []
98
-
99
- for repo_cls in self.repositories:
100
- repo = repo_cls()
101
- # database_key is None -> state-scoped (collection repeats per state db).
102
- is_state_scoped = repo.database_key is None and not repo.is_external
103
- target_databases = active_states if is_state_scoped else [repo.database_name]
104
-
105
- for db_name in target_databases:
106
- created = await repo.ensure_indexes(database_name=db_name)
107
- report.append({
108
- "repository": repo_cls.__name__,
109
- "database": db_name,
110
- "collection": repo.collection_name,
111
- "indexes": created,
112
- })
113
-
114
- return report