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.
Files changed (149) hide show
  1. {stackport-0.4.3/stackport.egg-info → stackport-0.4.4}/PKG-INFO +1 -1
  2. {stackport-0.4.3 → stackport-0.4.4}/backend/routes/dynamodb.py +66 -11
  3. {stackport-0.4.3 → stackport-0.4.4}/backend/routes/resources.py +5 -0
  4. {stackport-0.4.3 → stackport-0.4.4}/pyproject.toml +1 -1
  5. {stackport-0.4.3 → stackport-0.4.4/stackport.egg-info}/PKG-INFO +1 -1
  6. {stackport-0.4.3 → stackport-0.4.4}/tests/test_dynamodb_routes.py +167 -0
  7. stackport-0.4.4/tests/test_registries.py +142 -0
  8. stackport-0.4.3/tests/test_registries.py +0 -85
  9. {stackport-0.4.3 → stackport-0.4.4}/LICENSE +0 -0
  10. {stackport-0.4.3 → stackport-0.4.4}/MANIFEST.in +0 -0
  11. {stackport-0.4.3 → stackport-0.4.4}/README.md +0 -0
  12. {stackport-0.4.3 → stackport-0.4.4}/backend/__init__.py +0 -0
  13. {stackport-0.4.3 → stackport-0.4.4}/backend/aws_client.py +0 -0
  14. {stackport-0.4.3 → stackport-0.4.4}/backend/cache.py +0 -0
  15. {stackport-0.4.3 → stackport-0.4.4}/backend/cli.py +0 -0
  16. {stackport-0.4.3 → stackport-0.4.4}/backend/config.py +0 -0
  17. {stackport-0.4.3 → stackport-0.4.4}/backend/endpoint_store.py +0 -0
  18. {stackport-0.4.3 → stackport-0.4.4}/backend/learn/__init__.py +0 -0
  19. {stackport-0.4.3 → stackport-0.4.4}/backend/learn/content.py +0 -0
  20. {stackport-0.4.3 → stackport-0.4.4}/backend/learn/hotspots.py +0 -0
  21. {stackport-0.4.3 → stackport-0.4.4}/backend/learn/trails/aws-first-steps.json +0 -0
  22. {stackport-0.4.3 → stackport-0.4.4}/backend/learn/verify.py +0 -0
  23. {stackport-0.4.3 → stackport-0.4.4}/backend/learn_store.py +0 -0
  24. {stackport-0.4.3 → stackport-0.4.4}/backend/main.py +0 -0
  25. {stackport-0.4.3 → stackport-0.4.4}/backend/routes/__init__.py +0 -0
  26. {stackport-0.4.3 → stackport-0.4.4}/backend/routes/common.py +0 -0
  27. {stackport-0.4.3 → stackport-0.4.4}/backend/routes/ec2.py +0 -0
  28. {stackport-0.4.3 → stackport-0.4.4}/backend/routes/endpoints.py +0 -0
  29. {stackport-0.4.3 → stackport-0.4.4}/backend/routes/iam.py +0 -0
  30. {stackport-0.4.3 → stackport-0.4.4}/backend/routes/lambda_svc.py +0 -0
  31. {stackport-0.4.3 → stackport-0.4.4}/backend/routes/learn.py +0 -0
  32. {stackport-0.4.3 → stackport-0.4.4}/backend/routes/logs.py +0 -0
  33. {stackport-0.4.3 → stackport-0.4.4}/backend/routes/monitoring.py +0 -0
  34. {stackport-0.4.3 → stackport-0.4.4}/backend/routes/rds.py +0 -0
  35. {stackport-0.4.3 → stackport-0.4.4}/backend/routes/s3.py +0 -0
  36. {stackport-0.4.3 → stackport-0.4.4}/backend/routes/secretsmanager.py +0 -0
  37. {stackport-0.4.3 → stackport-0.4.4}/backend/routes/sns.py +0 -0
  38. {stackport-0.4.3 → stackport-0.4.4}/backend/routes/sqs.py +0 -0
  39. {stackport-0.4.3 → stackport-0.4.4}/backend/routes/stats.py +0 -0
  40. {stackport-0.4.3 → stackport-0.4.4}/backend/routes/stepfunctions.py +0 -0
  41. {stackport-0.4.3 → stackport-0.4.4}/backend/routes/tags.py +0 -0
  42. {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/__init__.py +0 -0
  43. {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/dynamodb.py +0 -0
  44. {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/endpoints.py +0 -0
  45. {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/lambda_svc.py +0 -0
  46. {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/learn.py +0 -0
  47. {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/logs.py +0 -0
  48. {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/monitoring.py +0 -0
  49. {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/s3.py +0 -0
  50. {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/secretsmanager.py +0 -0
  51. {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/sns.py +0 -0
  52. {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/sqs.py +0 -0
  53. {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/stepfunctions.py +0 -0
  54. {stackport-0.4.3 → stackport-0.4.4}/backend/schemas/tags.py +0 -0
  55. {stackport-0.4.3 → stackport-0.4.4}/backend/websocket.py +0 -0
  56. {stackport-0.4.3 → stackport-0.4.4}/setup.cfg +0 -0
  57. {stackport-0.4.3 → stackport-0.4.4}/stackport.egg-info/SOURCES.txt +0 -0
  58. {stackport-0.4.3 → stackport-0.4.4}/stackport.egg-info/dependency_links.txt +0 -0
  59. {stackport-0.4.3 → stackport-0.4.4}/stackport.egg-info/entry_points.txt +0 -0
  60. {stackport-0.4.3 → stackport-0.4.4}/stackport.egg-info/requires.txt +0 -0
  61. {stackport-0.4.3 → stackport-0.4.4}/stackport.egg-info/top_level.txt +0 -0
  62. {stackport-0.4.3 → stackport-0.4.4}/tests/test_cache.py +0 -0
  63. {stackport-0.4.3 → stackport-0.4.4}/tests/test_cli.py +0 -0
  64. {stackport-0.4.3 → stackport-0.4.4}/tests/test_client.py +0 -0
  65. {stackport-0.4.3 → stackport-0.4.4}/tests/test_config.py +0 -0
  66. {stackport-0.4.3 → stackport-0.4.4}/tests/test_create_resources.py +0 -0
  67. {stackport-0.4.3 → stackport-0.4.4}/tests/test_ec2_routes.py +0 -0
  68. {stackport-0.4.3 → stackport-0.4.4}/tests/test_endpoint_store.py +0 -0
  69. {stackport-0.4.3 → stackport-0.4.4}/tests/test_endpoints.py +0 -0
  70. {stackport-0.4.3 → stackport-0.4.4}/tests/test_iam_routes.py +0 -0
  71. {stackport-0.4.3 → stackport-0.4.4}/tests/test_lambda_routes.py +0 -0
  72. {stackport-0.4.3 → stackport-0.4.4}/tests/test_learn_content.py +0 -0
  73. {stackport-0.4.3 → stackport-0.4.4}/tests/test_learn_routes.py +0 -0
  74. {stackport-0.4.3 → stackport-0.4.4}/tests/test_learn_store.py +0 -0
  75. {stackport-0.4.3 → stackport-0.4.4}/tests/test_logs_routes.py +0 -0
  76. {stackport-0.4.3 → stackport-0.4.4}/tests/test_logs_tail.py +0 -0
  77. {stackport-0.4.3 → stackport-0.4.4}/tests/test_monitoring_routes.py +0 -0
  78. {stackport-0.4.3 → stackport-0.4.4}/tests/test_multi_endpoint.py +0 -0
  79. {stackport-0.4.3 → stackport-0.4.4}/tests/test_rds_routes.py +0 -0
  80. {stackport-0.4.3 → stackport-0.4.4}/tests/test_readonly_middleware.py +0 -0
  81. {stackport-0.4.3 → stackport-0.4.4}/tests/test_routes.py +0 -0
  82. {stackport-0.4.3 → stackport-0.4.4}/tests/test_s3_routes.py +0 -0
  83. {stackport-0.4.3 → stackport-0.4.4}/tests/test_s3_upload_limit_env.py +0 -0
  84. {stackport-0.4.3 → stackport-0.4.4}/tests/test_secretsmanager_routes.py +0 -0
  85. {stackport-0.4.3 → stackport-0.4.4}/tests/test_sns_routes.py +0 -0
  86. {stackport-0.4.3 → stackport-0.4.4}/tests/test_sqs_routes.py +0 -0
  87. {stackport-0.4.3 → stackport-0.4.4}/tests/test_tags_routes.py +0 -0
  88. {stackport-0.4.3 → stackport-0.4.4}/tests/test_websocket.py +0 -0
  89. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/CloudscapeAbout-BRbsDskR.js +0 -0
  90. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/CloudscapeDashboard-j2f4Elz2.js +0 -0
  91. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/CloudscapeLearn-CVW3k3X0.js +0 -0
  92. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/CloudscapeResourceBrowser-CgWdgn20.js +0 -0
  93. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/CloudscapeResourceBrowser-CzwePG-V.css +0 -0
  94. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/CloudscapeSettings-CIXFvPKv.js +0 -0
  95. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/CloudscapeShell-BXHVWjDE.css +0 -0
  96. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/CloudscapeShell-DNfhSg0O.js +0 -0
  97. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/StateMachineGraph-DvF_kIOO.js +0 -0
  98. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-BC7E-Fm4.js +0 -0
  99. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-BLdRlH9d.js +0 -0
  100. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-Ba-oOcd3.js +0 -0
  101. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-BkSgVQ0X.js +0 -0
  102. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-C5z3cir-.js +0 -0
  103. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-DE--A2EE.css +0 -0
  104. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-DKIdXFX0.css +0 -0
  105. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-DNBdeKId.css +0 -0
  106. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-DoY0HscW.css +0 -0
  107. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-DrkkXAoG.css +0 -0
  108. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-Dw6TGweb.js +0 -0
  109. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/index-Vf0UFiCT.css +0 -0
  110. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/stepfunctions-graph-B6nvU4OB.js +0 -0
  111. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/assets/utils-Cfmwk2UT.js +0 -0
  112. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/acm.svg +0 -0
  113. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/apigateway.svg +0 -0
  114. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/appsync.svg +0 -0
  115. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/athena.svg +0 -0
  116. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/cloudformation.svg +0 -0
  117. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/cloudfront.svg +0 -0
  118. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/cognito-idp.svg +0 -0
  119. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/dynamodb.svg +0 -0
  120. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/ec2.svg +0 -0
  121. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/ecr.svg +0 -0
  122. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/ecs.svg +0 -0
  123. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/elasticache.svg +0 -0
  124. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
  125. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
  126. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
  127. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/events.svg +0 -0
  128. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/firehose.svg +0 -0
  129. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/glue.svg +0 -0
  130. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/iam.svg +0 -0
  131. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/kinesis.svg +0 -0
  132. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/kms.svg +0 -0
  133. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/lambda.svg +0 -0
  134. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/logs.svg +0 -0
  135. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/monitoring.svg +0 -0
  136. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/rds.svg +0 -0
  137. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/route53.svg +0 -0
  138. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/s3.svg +0 -0
  139. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/secretsmanager.svg +0 -0
  140. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/ses.svg +0 -0
  141. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/sns.svg +0 -0
  142. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/sqs.svg +0 -0
  143. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/ssm.svg +0 -0
  144. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/stepfunctions.svg +0 -0
  145. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/aws-icons/wafv2.svg +0 -0
  146. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/favicon.png +0 -0
  147. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/favicon.svg +0 -0
  148. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/index.html +0 -0
  149. {stackport-0.4.3 → stackport-0.4.4}/ui/dist/stackport-logo.svg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackport
3
- Version: 0.4.3
3
+ Version: 0.4.4
4
4
  Summary: Universal AWS resource browser for local emulators
5
5
  Author: Davi Reis Vieira
6
6
  License: MIT
@@ -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": {partition_key_type: request.partition_key_value}}
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"] = {sort_key_type: request.sort_key_value}
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"] = {sort_key_type: request.sort_key_value}
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"] = {sort_key_type: request.sort_key_value}
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackport"
3
- version = "0.4.3"
3
+ version = "0.4.4"
4
4
  description = "Universal AWS resource browser for local emulators"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackport
3
- Version: 0.4.3
3
+ Version: 0.4.4
4
4
  Summary: Universal AWS resource browser for local emulators
5
5
  Author: Davi Reis Vieira
6
6
  License: MIT
@@ -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