stackport 0.4.3__tar.gz → 0.4.4__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.
- {stackport-0.4.3/stackport.egg-info → stackport-0.4.4}/PKG-INFO +1 -1
- {stackport-0.4.3 → stackport-0.4.4}/backend/routes/dynamodb.py +66 -11
- {stackport-0.4.3 → stackport-0.4.4}/backend/routes/resources.py +5 -0
- {stackport-0.4.3 → stackport-0.4.4}/pyproject.toml +1 -1
- {stackport-0.4.3 → stackport-0.4.4/stackport.egg-info}/PKG-INFO +1 -1
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_dynamodb_routes.py +167 -0
- stackport-0.4.4/tests/test_registries.py +142 -0
- stackport-0.4.3/tests/test_registries.py +0 -85
- {stackport-0.4.3 → stackport-0.4.4}/LICENSE +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/MANIFEST.in +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/README.md +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/__init__.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/aws_client.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/cache.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/cli.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/config.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/endpoint_store.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/learn/__init__.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/learn/content.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/learn/hotspots.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/learn/trails/aws-first-steps.json +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/learn/verify.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/learn_store.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/main.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/routes/__init__.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/routes/common.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/routes/ec2.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/routes/endpoints.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/routes/iam.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/routes/lambda_svc.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/routes/learn.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/routes/logs.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/routes/monitoring.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/routes/rds.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/routes/s3.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/routes/secretsmanager.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/routes/sns.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/routes/sqs.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/routes/stats.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/routes/stepfunctions.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/routes/tags.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/__init__.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/dynamodb.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/endpoints.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/lambda_svc.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/learn.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/logs.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/monitoring.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/s3.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/secretsmanager.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/sns.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/sqs.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/stepfunctions.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/tags.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/backend/websocket.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/setup.cfg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/stackport.egg-info/SOURCES.txt +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/stackport.egg-info/dependency_links.txt +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/stackport.egg-info/entry_points.txt +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/stackport.egg-info/requires.txt +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/stackport.egg-info/top_level.txt +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_cache.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_cli.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_client.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_config.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_create_resources.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_ec2_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_endpoint_store.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_endpoints.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_iam_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_lambda_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_learn_content.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_learn_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_learn_store.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_logs_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_logs_tail.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_monitoring_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_multi_endpoint.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_rds_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_readonly_middleware.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_s3_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_s3_upload_limit_env.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_secretsmanager_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_sns_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_sqs_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_tags_routes.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/tests/test_websocket.py +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/CloudscapeAbout-BRbsDskR.js +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/CloudscapeDashboard-j2f4Elz2.js +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/CloudscapeLearn-CVW3k3X0.js +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/CloudscapeResourceBrowser-CgWdgn20.js +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/CloudscapeResourceBrowser-CzwePG-V.css +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/CloudscapeSettings-CIXFvPKv.js +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/CloudscapeShell-BXHVWjDE.css +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/CloudscapeShell-DNfhSg0O.js +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/StateMachineGraph-DvF_kIOO.js +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-BC7E-Fm4.js +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-BLdRlH9d.js +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-Ba-oOcd3.js +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-BkSgVQ0X.js +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-C5z3cir-.js +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-DE--A2EE.css +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-DKIdXFX0.css +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-DNBdeKId.css +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-DoY0HscW.css +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-DrkkXAoG.css +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-Dw6TGweb.js +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-Vf0UFiCT.css +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/stepfunctions-graph-B6nvU4OB.js +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/utils-Cfmwk2UT.js +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/acm.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/apigateway.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/appsync.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/athena.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/cloudformation.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/cloudfront.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/cognito-idp.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/dynamodb.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/ec2.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/ecr.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/ecs.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/elasticache.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/events.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/firehose.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/glue.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/iam.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/kinesis.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/kms.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/lambda.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/logs.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/monitoring.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/rds.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/route53.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/s3.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/secretsmanager.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/ses.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/sns.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/sqs.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/ssm.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/stepfunctions.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/wafv2.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/favicon.png +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/favicon.svg +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/index.html +0 -0
- {stackport-0.4.3 → stackport-0.4.4}/ui/dist/stackport-logo.svg +0 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import base64
|
|
2
|
+
import binascii
|
|
2
3
|
import json
|
|
3
4
|
import logging
|
|
4
5
|
from typing import Any
|
|
@@ -27,6 +28,58 @@ def _plain_to_dynamodb_item(plain: dict[str, Any]) -> dict[str, Any]:
|
|
|
27
28
|
return {k: _serializer.serialize(v) for k, v in plain.items()}
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
# Binary (B / BS) travels as base64 over StackPort's HTTP API, the same way AWS
|
|
32
|
+
# writes it in DynamoDB JSON. boto3 hands us raw bytes, which JSON cannot carry.
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _to_json_safe(obj: Any) -> Any:
|
|
36
|
+
"""Replace every bytes value with its base64 text. Safe to apply to whole
|
|
37
|
+
low-level responses: DynamoDB only returns bytes inside B and BS."""
|
|
38
|
+
if isinstance(obj, (bytes, bytearray)):
|
|
39
|
+
return base64.b64encode(obj).decode("ascii")
|
|
40
|
+
if isinstance(obj, dict):
|
|
41
|
+
return {k: _to_json_safe(v) for k, v in obj.items()}
|
|
42
|
+
if isinstance(obj, list):
|
|
43
|
+
return [_to_json_safe(v) for v in obj]
|
|
44
|
+
return obj
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _b64_to_bytes(value: Any, name: str) -> bytes:
|
|
48
|
+
if isinstance(value, (bytes, bytearray)):
|
|
49
|
+
return bytes(value)
|
|
50
|
+
try:
|
|
51
|
+
return base64.b64decode(value, validate=True)
|
|
52
|
+
except (binascii.Error, TypeError, ValueError) as e:
|
|
53
|
+
raise HTTPException(status_code=400, detail=f"Attribute {name!r} is not valid base64 for type B") from e
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _attr_from_json(av: Any, name: str) -> Any:
|
|
57
|
+
"""Inverse of _to_json_safe for one DynamoDB attribute value. Type-aware, so
|
|
58
|
+
strings under S stay strings and only B / BS are decoded."""
|
|
59
|
+
if not isinstance(av, dict):
|
|
60
|
+
return av
|
|
61
|
+
if "B" in av:
|
|
62
|
+
return {**av, "B": _b64_to_bytes(av["B"], name)}
|
|
63
|
+
if "BS" in av and isinstance(av["BS"], list):
|
|
64
|
+
return {**av, "BS": [_b64_to_bytes(v, name) for v in av["BS"]]}
|
|
65
|
+
if "L" in av and isinstance(av["L"], list):
|
|
66
|
+
return {**av, "L": [_attr_from_json(v, name) for v in av["L"]]}
|
|
67
|
+
if "M" in av and isinstance(av["M"], dict):
|
|
68
|
+
return {**av, "M": {k: _attr_from_json(v, f"{name}.{k}") for k, v in av["M"].items()}}
|
|
69
|
+
return av
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _item_from_json(item: dict[str, Any]) -> dict[str, Any]:
|
|
73
|
+
return {k: _attr_from_json(v, k) for k, v in item.items()}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _key_value(attr_type: str, value: str, name: str) -> dict[str, Any]:
|
|
77
|
+
"""Typed key value for a query condition. Binary keys arrive as base64."""
|
|
78
|
+
if attr_type == "B":
|
|
79
|
+
return {"B": _b64_to_bytes(value, name)}
|
|
80
|
+
return {attr_type: value}
|
|
81
|
+
|
|
82
|
+
|
|
30
83
|
def _get_partition_sort_keys(dynamodb: Any, table_name: str) -> tuple[str | None, str | None]:
|
|
31
84
|
table_resp = dynamodb.describe_table(TableName=table_name)
|
|
32
85
|
key_schema = table_resp["Table"].get("KeySchema", [])
|
|
@@ -56,7 +109,7 @@ def _coerce_item_dict(raw: dict[str, Any], item_format: str) -> dict[str, Any]:
|
|
|
56
109
|
return _plain_to_dynamodb_item(raw)
|
|
57
110
|
except TypeError as e:
|
|
58
111
|
raise HTTPException(status_code=400, detail=f"Could not convert plain item to DynamoDB types: {e}") from e
|
|
59
|
-
return raw
|
|
112
|
+
return _item_from_json(raw)
|
|
60
113
|
|
|
61
114
|
|
|
62
115
|
def _coerce_key_dict(raw: dict[str, Any], item_format: str) -> dict[str, Any]:
|
|
@@ -65,7 +118,7 @@ def _coerce_key_dict(raw: dict[str, Any], item_format: str) -> dict[str, Any]:
|
|
|
65
118
|
return _plain_to_dynamodb_item(raw)
|
|
66
119
|
except TypeError as e:
|
|
67
120
|
raise HTTPException(status_code=400, detail=f"Could not convert plain key to DynamoDB types: {e}") from e
|
|
68
|
-
return raw
|
|
121
|
+
return _item_from_json(raw)
|
|
69
122
|
|
|
70
123
|
|
|
71
124
|
def _invalidate_table_item_count(table_name: str, endpoint_url: str | None) -> None:
|
|
@@ -237,7 +290,7 @@ def scan_table(
|
|
|
237
290
|
if exclusive_start_key:
|
|
238
291
|
try:
|
|
239
292
|
decoded = base64.b64decode(exclusive_start_key).decode("utf-8")
|
|
240
|
-
scan_params["ExclusiveStartKey"] = json.loads(decoded)
|
|
293
|
+
scan_params["ExclusiveStartKey"] = _item_from_json(json.loads(decoded))
|
|
241
294
|
except Exception:
|
|
242
295
|
logger.debug("Invalid exclusive_start_key", exc_info=True)
|
|
243
296
|
|
|
@@ -248,11 +301,11 @@ def scan_table(
|
|
|
248
301
|
last_evaluated_key = resp.get("LastEvaluatedKey")
|
|
249
302
|
next_token = None
|
|
250
303
|
if last_evaluated_key:
|
|
251
|
-
next_token = base64.b64encode(json.dumps(last_evaluated_key).encode("utf-8")).decode("utf-8")
|
|
304
|
+
next_token = base64.b64encode(json.dumps(_to_json_safe(last_evaluated_key)).encode("utf-8")).decode("utf-8")
|
|
252
305
|
|
|
253
306
|
return {
|
|
254
307
|
"table": name,
|
|
255
|
-
"items": items,
|
|
308
|
+
"items": _to_json_safe(items),
|
|
256
309
|
"count": len(items),
|
|
257
310
|
"scanned_count": resp.get("ScannedCount", len(items)),
|
|
258
311
|
"next_token": next_token,
|
|
@@ -285,19 +338,19 @@ def query_table(name: str, request: QueryRequest, ep: EndpointInfo = Depends(get
|
|
|
285
338
|
# Build key condition expression
|
|
286
339
|
key_condition = f"#{partition_key} = :pk"
|
|
287
340
|
expression_attr_names = {f"#{partition_key}": partition_key}
|
|
288
|
-
expression_attr_values = {":pk":
|
|
341
|
+
expression_attr_values = {":pk": _key_value(partition_key_type, request.partition_key_value, partition_key)}
|
|
289
342
|
|
|
290
343
|
if sort_key and request.sort_key_value:
|
|
291
344
|
sort_key_type = attribute_defs.get(sort_key, "S")
|
|
292
345
|
if request.sort_key_operator == "=":
|
|
293
346
|
key_condition += f" AND #{sort_key} = :sk"
|
|
294
|
-
expression_attr_values[":sk"] =
|
|
347
|
+
expression_attr_values[":sk"] = _key_value(sort_key_type, request.sort_key_value, sort_key)
|
|
295
348
|
elif request.sort_key_operator in ("<", "<=", ">", ">="):
|
|
296
349
|
key_condition += f" AND #{sort_key} {request.sort_key_operator} :sk"
|
|
297
|
-
expression_attr_values[":sk"] =
|
|
350
|
+
expression_attr_values[":sk"] = _key_value(sort_key_type, request.sort_key_value, sort_key)
|
|
298
351
|
elif request.sort_key_operator == "BEGINS_WITH":
|
|
299
352
|
key_condition += f" AND begins_with(#{sort_key}, :sk)"
|
|
300
|
-
expression_attr_values[":sk"] =
|
|
353
|
+
expression_attr_values[":sk"] = _key_value(sort_key_type, request.sort_key_value, sort_key)
|
|
301
354
|
expression_attr_names[f"#{sort_key}"] = sort_key
|
|
302
355
|
|
|
303
356
|
query_params = {
|
|
@@ -313,10 +366,12 @@ def query_table(name: str, request: QueryRequest, ep: EndpointInfo = Depends(get
|
|
|
313
366
|
|
|
314
367
|
return {
|
|
315
368
|
"table": name,
|
|
316
|
-
"items": items,
|
|
369
|
+
"items": _to_json_safe(items),
|
|
317
370
|
"count": len(items),
|
|
318
371
|
"scanned_count": resp.get("ScannedCount", len(items)),
|
|
319
372
|
}
|
|
373
|
+
except HTTPException:
|
|
374
|
+
raise
|
|
320
375
|
except Exception as e:
|
|
321
376
|
logger.error("Failed to query table %s: %s", name, e, exc_info=True)
|
|
322
377
|
return {"error": str(e), "items": [], "count": 0}
|
|
@@ -411,7 +466,7 @@ def batch_write_items(
|
|
|
411
466
|
return {
|
|
412
467
|
"ok": True,
|
|
413
468
|
"table": name,
|
|
414
|
-
"unprocessed": uproc,
|
|
469
|
+
"unprocessed": _to_json_safe(uproc),
|
|
415
470
|
"message": "Some items were not processed; retry with returned keys.",
|
|
416
471
|
}
|
|
417
472
|
|
|
@@ -85,6 +85,11 @@ _PREFERRED_ID_FIELD: dict[tuple[str, str], str] = {
|
|
|
85
85
|
("cognito-idp", "user_pools"): "Id",
|
|
86
86
|
("apigateway", "rest_apis"): "id",
|
|
87
87
|
("apigateway", "apis"): "ApiId",
|
|
88
|
+
("iam", "policies"): "Arn",
|
|
89
|
+
("ec2", "subnets"): "SubnetId",
|
|
90
|
+
("ec2", "security_groups"): "GroupId",
|
|
91
|
+
("elasticfilesystem", "file_systems"): "FileSystemId",
|
|
92
|
+
("rds", "db_clusters"): "DBClusterIdentifier",
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
# Known ID field names for extracting a resource identifier from list results
|
|
@@ -407,3 +407,170 @@ class TestItemWrites:
|
|
|
407
407
|
assert any(k.endswith(":dynamodb:item_count:t1") for k in cache_keys), (
|
|
408
408
|
f"expected item_count cache invalidation, got calls: {cache_keys}"
|
|
409
409
|
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# Binary attributes (#176). b"\x00\x8e\xff" is not valid UTF-8, which is what
|
|
413
|
+
# crashed FastAPI's encoder; its base64 form is "AI7/".
|
|
414
|
+
RAW = b"\x00\x8e\xff"
|
|
415
|
+
RAW_B64 = "AI7/"
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _hash_table(mock_ddb, key_type="S"):
|
|
419
|
+
mock_ddb.describe_table.return_value = {
|
|
420
|
+
"Table": {
|
|
421
|
+
"KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
|
|
422
|
+
"AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": key_type}],
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class TestBinaryAttributes:
|
|
428
|
+
@patch("backend.routes.dynamodb.get_client")
|
|
429
|
+
def test_scan_returns_binary_as_base64(self, mock_get_client):
|
|
430
|
+
mock_ddb = MagicMock()
|
|
431
|
+
mock_get_client.return_value = mock_ddb
|
|
432
|
+
mock_ddb.scan.return_value = {
|
|
433
|
+
"Items": [
|
|
434
|
+
{
|
|
435
|
+
"pk": {"S": "a"},
|
|
436
|
+
"blob": {"B": RAW},
|
|
437
|
+
"set": {"BS": [RAW, b"ok"]},
|
|
438
|
+
"nested": {"M": {"inner": {"B": RAW}, "list": {"L": [{"B": RAW}, {"S": "keep"}]}}},
|
|
439
|
+
}
|
|
440
|
+
],
|
|
441
|
+
"ScannedCount": 1,
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
resp = client.get("/api/dynamodb/tables/t1/items")
|
|
445
|
+
assert resp.status_code == 200
|
|
446
|
+
item = resp.json()["items"][0]
|
|
447
|
+
assert item["pk"] == {"S": "a"}
|
|
448
|
+
assert item["blob"] == {"B": RAW_B64}
|
|
449
|
+
assert item["set"] == {"BS": [RAW_B64, "b2s="]}
|
|
450
|
+
assert item["nested"]["M"]["inner"] == {"B": RAW_B64}
|
|
451
|
+
assert item["nested"]["M"]["list"]["L"] == [{"B": RAW_B64}, {"S": "keep"}]
|
|
452
|
+
|
|
453
|
+
@patch("backend.routes.dynamodb.get_client")
|
|
454
|
+
def test_scan_paginates_on_a_binary_key(self, mock_get_client):
|
|
455
|
+
mock_ddb = MagicMock()
|
|
456
|
+
mock_get_client.return_value = mock_ddb
|
|
457
|
+
mock_ddb.scan.return_value = {
|
|
458
|
+
"Items": [{"pk": {"B": RAW}}],
|
|
459
|
+
"ScannedCount": 1,
|
|
460
|
+
"LastEvaluatedKey": {"pk": {"B": RAW}},
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
first = client.get("/api/dynamodb/tables/t1/items?limit=1")
|
|
464
|
+
assert first.status_code == 200
|
|
465
|
+
body = first.json()
|
|
466
|
+
assert "error" not in body
|
|
467
|
+
assert body["next_token"]
|
|
468
|
+
|
|
469
|
+
client.get(f"/api/dynamodb/tables/t1/items?limit=1&exclusive_start_key={body['next_token']}")
|
|
470
|
+
assert mock_ddb.scan.call_args[1]["ExclusiveStartKey"] == {"pk": {"B": RAW}}
|
|
471
|
+
|
|
472
|
+
@patch("backend.routes.dynamodb.get_client")
|
|
473
|
+
def test_query_on_a_binary_partition_key(self, mock_get_client):
|
|
474
|
+
mock_ddb = MagicMock()
|
|
475
|
+
mock_get_client.return_value = mock_ddb
|
|
476
|
+
_hash_table(mock_ddb, key_type="B")
|
|
477
|
+
mock_ddb.query.return_value = {"Items": [{"pk": {"B": RAW}}], "ScannedCount": 1}
|
|
478
|
+
|
|
479
|
+
resp = client.post("/api/dynamodb/tables/t1/query", json={"partition_key_value": RAW_B64})
|
|
480
|
+
assert resp.status_code == 200
|
|
481
|
+
assert resp.json()["items"] == [{"pk": {"B": RAW_B64}}]
|
|
482
|
+
assert mock_ddb.query.call_args[1]["ExpressionAttributeValues"][":pk"] == {"B": RAW}
|
|
483
|
+
|
|
484
|
+
@patch("backend.routes.dynamodb.get_client")
|
|
485
|
+
def test_query_with_invalid_base64_key_is_400(self, mock_get_client):
|
|
486
|
+
mock_ddb = MagicMock()
|
|
487
|
+
mock_get_client.return_value = mock_ddb
|
|
488
|
+
_hash_table(mock_ddb, key_type="B")
|
|
489
|
+
|
|
490
|
+
resp = client.post("/api/dynamodb/tables/t1/query", json={"partition_key_value": "not base64!"})
|
|
491
|
+
assert resp.status_code == 400
|
|
492
|
+
mock_ddb.query.assert_not_called()
|
|
493
|
+
|
|
494
|
+
@patch("backend.routes.dynamodb.get_client")
|
|
495
|
+
def test_put_decodes_base64_to_bytes(self, mock_get_client):
|
|
496
|
+
mock_ddb = MagicMock()
|
|
497
|
+
mock_get_client.return_value = mock_ddb
|
|
498
|
+
_hash_table(mock_ddb)
|
|
499
|
+
|
|
500
|
+
resp = client.put(
|
|
501
|
+
"/api/dynamodb/tables/t1/items",
|
|
502
|
+
json={
|
|
503
|
+
"item": {"pk": {"S": "a"}, "blob": {"B": RAW_B64}, "set": {"BS": [RAW_B64]}, "m": {"M": {"x": {"B": RAW_B64}}}},
|
|
504
|
+
"item_format": "dynamodb",
|
|
505
|
+
},
|
|
506
|
+
)
|
|
507
|
+
assert resp.status_code == 200
|
|
508
|
+
item = mock_ddb.put_item.call_args[1]["Item"]
|
|
509
|
+
assert item["pk"] == {"S": "a"}
|
|
510
|
+
assert item["blob"] == {"B": RAW}
|
|
511
|
+
assert item["set"] == {"BS": [RAW]}
|
|
512
|
+
assert item["m"] == {"M": {"x": {"B": RAW}}}
|
|
513
|
+
|
|
514
|
+
@patch("backend.routes.dynamodb.get_client")
|
|
515
|
+
def test_put_with_invalid_base64_is_400(self, mock_get_client):
|
|
516
|
+
mock_ddb = MagicMock()
|
|
517
|
+
mock_get_client.return_value = mock_ddb
|
|
518
|
+
_hash_table(mock_ddb)
|
|
519
|
+
|
|
520
|
+
resp = client.put(
|
|
521
|
+
"/api/dynamodb/tables/t1/items",
|
|
522
|
+
json={"item": {"pk": {"S": "a"}, "blob": {"B": "not base64!"}}, "item_format": "dynamodb"},
|
|
523
|
+
)
|
|
524
|
+
assert resp.status_code == 400
|
|
525
|
+
assert "blob" in resp.json()["detail"]
|
|
526
|
+
mock_ddb.put_item.assert_not_called()
|
|
527
|
+
|
|
528
|
+
@patch("backend.routes.dynamodb.get_client")
|
|
529
|
+
def test_delete_and_batch_delete_by_binary_key(self, mock_get_client):
|
|
530
|
+
mock_ddb = MagicMock()
|
|
531
|
+
mock_get_client.return_value = mock_ddb
|
|
532
|
+
_hash_table(mock_ddb, key_type="B")
|
|
533
|
+
mock_ddb.batch_write_item.return_value = {"UnprocessedItems": {}}
|
|
534
|
+
|
|
535
|
+
resp = client.request(
|
|
536
|
+
"DELETE", "/api/dynamodb/tables/t1/items", json={"key": {"pk": {"B": RAW_B64}}, "item_format": "dynamodb"}
|
|
537
|
+
)
|
|
538
|
+
assert resp.status_code == 200
|
|
539
|
+
assert mock_ddb.delete_item.call_args[1]["Key"] == {"pk": {"B": RAW}}
|
|
540
|
+
|
|
541
|
+
resp = client.post(
|
|
542
|
+
"/api/dynamodb/tables/t1/items/batch",
|
|
543
|
+
json={"item_format": "dynamodb", "operations": [{"op": "delete", "key": {"pk": {"B": RAW_B64}}}]},
|
|
544
|
+
)
|
|
545
|
+
assert resp.status_code == 200
|
|
546
|
+
req = mock_ddb.batch_write_item.call_args[1]["RequestItems"]["t1"]
|
|
547
|
+
assert req == [{"DeleteRequest": {"Key": {"pk": {"B": RAW}}}}]
|
|
548
|
+
|
|
549
|
+
@patch("backend.routes.dynamodb.get_client")
|
|
550
|
+
def test_batch_unprocessed_binary_is_returned_as_base64(self, mock_get_client):
|
|
551
|
+
mock_ddb = MagicMock()
|
|
552
|
+
mock_get_client.return_value = mock_ddb
|
|
553
|
+
_hash_table(mock_ddb, key_type="B")
|
|
554
|
+
mock_ddb.batch_write_item.return_value = {
|
|
555
|
+
"UnprocessedItems": {"t1": [{"PutRequest": {"Item": {"pk": {"B": RAW}}}}]}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
resp = client.post(
|
|
559
|
+
"/api/dynamodb/tables/t1/items/batch",
|
|
560
|
+
json={"item_format": "dynamodb", "operations": [{"op": "put", "item": {"pk": {"B": RAW_B64}}}]},
|
|
561
|
+
)
|
|
562
|
+
assert resp.status_code == 200
|
|
563
|
+
assert resp.json()["unprocessed"] == {"t1": [{"PutRequest": {"Item": {"pk": {"B": RAW_B64}}}}]}
|
|
564
|
+
|
|
565
|
+
@patch("backend.routes.dynamodb.get_client")
|
|
566
|
+
def test_scan_output_round_trips_through_put(self, mock_get_client):
|
|
567
|
+
"""What Edit does: take an item from the scan and save it unchanged."""
|
|
568
|
+
mock_ddb = MagicMock()
|
|
569
|
+
mock_get_client.return_value = mock_ddb
|
|
570
|
+
_hash_table(mock_ddb)
|
|
571
|
+
original = {"pk": {"S": "a"}, "blob": {"B": RAW}, "set": {"BS": [RAW, b"ok"]}}
|
|
572
|
+
mock_ddb.scan.return_value = {"Items": [original], "ScannedCount": 1}
|
|
573
|
+
|
|
574
|
+
scanned = client.get("/api/dynamodb/tables/t1/items").json()["items"][0]
|
|
575
|
+
client.put("/api/dynamodb/tables/t1/items", json={"item": scanned, "item_format": "dynamodb"})
|
|
576
|
+
assert mock_ddb.put_item.call_args[1]["Item"] == original
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Validate SERVICE_REGISTRY, DESCRIBE_REGISTRY, and _METHOD_KWARGS consistency."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import io
|
|
5
|
+
from unittest.mock import MagicMock
|
|
6
|
+
|
|
7
|
+
import boto3
|
|
8
|
+
import pytest
|
|
9
|
+
from click.testing import CliRunner
|
|
10
|
+
|
|
11
|
+
from backend.cli import cli
|
|
12
|
+
from backend.routes.resources import (
|
|
13
|
+
_ID_FIELDS,
|
|
14
|
+
_PREFERRED_ID_FIELD,
|
|
15
|
+
DESCRIBE_REGISTRY,
|
|
16
|
+
_extract_id,
|
|
17
|
+
_summarize_item,
|
|
18
|
+
)
|
|
19
|
+
from backend.routes.stats import _METHOD_KWARGS, SERVICE_REGISTRY
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestServiceRegistry:
|
|
23
|
+
def test_all_entries_are_4_tuples(self):
|
|
24
|
+
for service, entries in SERVICE_REGISTRY.items():
|
|
25
|
+
for entry in entries:
|
|
26
|
+
assert len(entry) == 4, f"{service}: entry {entry} is not a 4-tuple"
|
|
27
|
+
resource_type, boto3_service, method, response_key = entry
|
|
28
|
+
assert isinstance(resource_type, str) and resource_type
|
|
29
|
+
assert isinstance(boto3_service, str) and boto3_service
|
|
30
|
+
assert isinstance(method, str) and method
|
|
31
|
+
assert isinstance(response_key, str) and response_key
|
|
32
|
+
|
|
33
|
+
def test_no_duplicate_resource_types_per_service(self):
|
|
34
|
+
for service, entries in SERVICE_REGISTRY.items():
|
|
35
|
+
types = [e[0] for e in entries]
|
|
36
|
+
assert len(types) == len(set(types)), f"{service} has duplicate resource types"
|
|
37
|
+
|
|
38
|
+
def test_expected_services_present(self):
|
|
39
|
+
expected = {"s3", "sqs", "sns", "dynamodb", "lambda", "iam", "ec2", "logs"}
|
|
40
|
+
assert expected.issubset(SERVICE_REGISTRY.keys())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestDescribeRegistry:
|
|
44
|
+
def test_all_entries_are_4_tuples(self):
|
|
45
|
+
for key, entry in DESCRIBE_REGISTRY.items():
|
|
46
|
+
assert len(entry) == 4, f"{key}: entry {entry} is not a 4-tuple"
|
|
47
|
+
boto3_service, method, id_param, response_key = entry
|
|
48
|
+
assert isinstance(boto3_service, str) and boto3_service
|
|
49
|
+
assert isinstance(method, str) and method
|
|
50
|
+
assert isinstance(id_param, str) and id_param
|
|
51
|
+
assert response_key is None or isinstance(response_key, str)
|
|
52
|
+
|
|
53
|
+
def test_keys_are_service_type_tuples(self):
|
|
54
|
+
for key in DESCRIBE_REGISTRY:
|
|
55
|
+
assert isinstance(key, tuple) and len(key) == 2
|
|
56
|
+
service, res_type = key
|
|
57
|
+
assert isinstance(service, str) and isinstance(res_type, str)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestMethodKwargs:
|
|
61
|
+
def test_keys_reference_valid_methods(self):
|
|
62
|
+
"""All _METHOD_KWARGS keys should reference methods that exist in SERVICE_REGISTRY."""
|
|
63
|
+
all_methods = set()
|
|
64
|
+
for entries in SERVICE_REGISTRY.values():
|
|
65
|
+
for _, boto3_service, method, _ in entries:
|
|
66
|
+
all_methods.add((boto3_service, method))
|
|
67
|
+
|
|
68
|
+
for key in _METHOD_KWARGS:
|
|
69
|
+
assert key in all_methods, (
|
|
70
|
+
f"_METHOD_KWARGS key {key} not found in SERVICE_REGISTRY methods"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def test_values_are_dicts(self):
|
|
74
|
+
for key, value in _METHOD_KWARGS.items():
|
|
75
|
+
assert isinstance(value, dict), f"{key}: value is not a dict"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TestIdFields:
|
|
79
|
+
def test_id_fields_are_strings(self):
|
|
80
|
+
for field in _ID_FIELDS:
|
|
81
|
+
assert isinstance(field, str) and field
|
|
82
|
+
|
|
83
|
+
def test_no_duplicates(self):
|
|
84
|
+
assert len(_ID_FIELDS) == len(set(_ID_FIELDS))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TestPreferredIdField:
|
|
88
|
+
@pytest.fixture(params=[
|
|
89
|
+
("apigateway", "apis", "ApiId", "ApiId"),
|
|
90
|
+
("iam", "policies", "Arn", "PolicyArn"),
|
|
91
|
+
("ec2", "subnets", "SubnetId", "SubnetIds"),
|
|
92
|
+
("ec2", "security_groups", "GroupId", "GroupIds"),
|
|
93
|
+
("elasticfilesystem", "file_systems", "FileSystemId", "FileSystemId"),
|
|
94
|
+
("rds", "db_clusters", "DBClusterIdentifier", "DBClusterIdentifier"),
|
|
95
|
+
], ids=lambda case: f"{case[0]}/{case[1]}")
|
|
96
|
+
def resource_case(self, request):
|
|
97
|
+
service, resource_type, id_field, id_param = request.param
|
|
98
|
+
entry = next(e for e in SERVICE_REGISTRY[service] if e[0] == resource_type)
|
|
99
|
+
_, boto_service, list_method, response_key = entry
|
|
100
|
+
client = boto3.client(
|
|
101
|
+
boto_service, region_name="us-east-1",
|
|
102
|
+
aws_access_key_id="test", aws_secret_access_key="test",
|
|
103
|
+
)
|
|
104
|
+
model = client.meta.service_model
|
|
105
|
+
operation = model.operation_model(client.meta.method_to_api_mapping[list_method])
|
|
106
|
+
shape = operation.output_shape.members[response_key].member
|
|
107
|
+
assert id_field in shape.members
|
|
108
|
+
_, describe_method, registered_param, _ = DESCRIBE_REGISTRY[(service, resource_type)]
|
|
109
|
+
assert registered_param == id_param
|
|
110
|
+
describe = model.operation_model(client.meta.method_to_api_mapping[describe_method])
|
|
111
|
+
assert id_param in describe.input_shape.members
|
|
112
|
+
|
|
113
|
+
# Include every member, even optional ones: sparse fixtures hide collisions.
|
|
114
|
+
# Unique field-name markers reveal which field wins; no AWS calls are made.
|
|
115
|
+
item = {field: f"value-of-{field}" for field in shape.members}
|
|
116
|
+
return service, resource_type, list_method, response_key, item, item[id_field]
|
|
117
|
+
|
|
118
|
+
def test_list_id_matches_describe_contract(self, resource_case):
|
|
119
|
+
service, resource_type, _, _, item, expected = resource_case
|
|
120
|
+
preferred = _PREFERRED_ID_FIELD.get((service, resource_type))
|
|
121
|
+
assert _extract_id(item, preferred) == expected
|
|
122
|
+
assert _summarize_item(item, preferred)["id"] == expected
|
|
123
|
+
|
|
124
|
+
@pytest.mark.parametrize("output", ["table", "csv"])
|
|
125
|
+
def test_cli_list_emits_describe_identifier(self, resource_case, output, monkeypatch):
|
|
126
|
+
service, resource_type, list_method, response_key, item, expected = resource_case
|
|
127
|
+
client = MagicMock()
|
|
128
|
+
# Other resource types listed by the same service are empty.
|
|
129
|
+
for _, _, method, key in SERVICE_REGISTRY[service]:
|
|
130
|
+
getattr(client, method).return_value = {key: []}
|
|
131
|
+
getattr(client, list_method).return_value = {response_key: [item]}
|
|
132
|
+
monkeypatch.setattr("backend.cli.get_client", lambda *args, **kwargs: client)
|
|
133
|
+
|
|
134
|
+
result = CliRunner().invoke(cli, ["list", service, "--output", output])
|
|
135
|
+
assert result.exit_code == 0, result.output
|
|
136
|
+
if output == "csv":
|
|
137
|
+
rows = list(csv.DictReader(io.StringIO(result.output)))
|
|
138
|
+
assert len(rows) == 1
|
|
139
|
+
assert rows[0]["resource_type"] == resource_type
|
|
140
|
+
assert rows[0]["resource_id"] == expected
|
|
141
|
+
else:
|
|
142
|
+
assert any(line.startswith(f" {expected}") for line in result.output.splitlines())
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
"""Validate SERVICE_REGISTRY, DESCRIBE_REGISTRY, and _METHOD_KWARGS consistency."""
|
|
2
|
-
|
|
3
|
-
from backend.routes.resources import (
|
|
4
|
-
DESCRIBE_REGISTRY,
|
|
5
|
-
_ID_FIELDS,
|
|
6
|
-
_PREFERRED_ID_FIELD,
|
|
7
|
-
_extract_id,
|
|
8
|
-
_summarize_item,
|
|
9
|
-
)
|
|
10
|
-
from backend.routes.stats import SERVICE_REGISTRY, _METHOD_KWARGS
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class TestServiceRegistry:
|
|
14
|
-
def test_all_entries_are_4_tuples(self):
|
|
15
|
-
for service, entries in SERVICE_REGISTRY.items():
|
|
16
|
-
for entry in entries:
|
|
17
|
-
assert len(entry) == 4, f"{service}: entry {entry} is not a 4-tuple"
|
|
18
|
-
resource_type, boto3_service, method, response_key = entry
|
|
19
|
-
assert isinstance(resource_type, str) and resource_type
|
|
20
|
-
assert isinstance(boto3_service, str) and boto3_service
|
|
21
|
-
assert isinstance(method, str) and method
|
|
22
|
-
assert isinstance(response_key, str) and response_key
|
|
23
|
-
|
|
24
|
-
def test_no_duplicate_resource_types_per_service(self):
|
|
25
|
-
for service, entries in SERVICE_REGISTRY.items():
|
|
26
|
-
types = [e[0] for e in entries]
|
|
27
|
-
assert len(types) == len(set(types)), f"{service} has duplicate resource types"
|
|
28
|
-
|
|
29
|
-
def test_expected_services_present(self):
|
|
30
|
-
expected = {"s3", "sqs", "sns", "dynamodb", "lambda", "iam", "ec2", "logs"}
|
|
31
|
-
assert expected.issubset(SERVICE_REGISTRY.keys())
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class TestDescribeRegistry:
|
|
35
|
-
def test_all_entries_are_4_tuples(self):
|
|
36
|
-
for key, entry in DESCRIBE_REGISTRY.items():
|
|
37
|
-
assert len(entry) == 4, f"{key}: entry {entry} is not a 4-tuple"
|
|
38
|
-
boto3_service, method, id_param, response_key = entry
|
|
39
|
-
assert isinstance(boto3_service, str) and boto3_service
|
|
40
|
-
assert isinstance(method, str) and method
|
|
41
|
-
assert isinstance(id_param, str) and id_param
|
|
42
|
-
assert response_key is None or isinstance(response_key, str)
|
|
43
|
-
|
|
44
|
-
def test_keys_are_service_type_tuples(self):
|
|
45
|
-
for key in DESCRIBE_REGISTRY:
|
|
46
|
-
assert isinstance(key, tuple) and len(key) == 2
|
|
47
|
-
service, res_type = key
|
|
48
|
-
assert isinstance(service, str) and isinstance(res_type, str)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
class TestMethodKwargs:
|
|
52
|
-
def test_keys_reference_valid_methods(self):
|
|
53
|
-
"""All _METHOD_KWARGS keys should reference methods that exist in SERVICE_REGISTRY."""
|
|
54
|
-
all_methods = set()
|
|
55
|
-
for entries in SERVICE_REGISTRY.values():
|
|
56
|
-
for _, boto3_service, method, _ in entries:
|
|
57
|
-
all_methods.add((boto3_service, method))
|
|
58
|
-
|
|
59
|
-
for key in _METHOD_KWARGS:
|
|
60
|
-
assert key in all_methods, (
|
|
61
|
-
f"_METHOD_KWARGS key {key} not found in SERVICE_REGISTRY methods"
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
def test_values_are_dicts(self):
|
|
65
|
-
for key, value in _METHOD_KWARGS.items():
|
|
66
|
-
assert isinstance(value, dict), f"{key}: value is not a dict"
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
class TestIdFields:
|
|
70
|
-
def test_id_fields_are_strings(self):
|
|
71
|
-
for field in _ID_FIELDS:
|
|
72
|
-
assert isinstance(field, str) and field
|
|
73
|
-
|
|
74
|
-
def test_no_duplicates(self):
|
|
75
|
-
assert len(_ID_FIELDS) == len(set(_ID_FIELDS))
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
class TestPreferredIdField:
|
|
79
|
-
def test_apigatewayv2_api_id_wins_over_name(self):
|
|
80
|
-
"""get_api takes ApiId, but the list item also has Name, which ranks first
|
|
81
|
-
in _ID_FIELDS — without the override the detail lookup 404s."""
|
|
82
|
-
item = {"ApiId": "acf2a85b", "Name": "my-api", "ProtocolType": "HTTP"}
|
|
83
|
-
preferred = _PREFERRED_ID_FIELD.get(("apigateway", "apis"))
|
|
84
|
-
assert _extract_id(item, preferred) == "acf2a85b"
|
|
85
|
-
assert _summarize_item(item, preferred)["id"] == "acf2a85b"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|