stackport 0.2.4__tar.gz → 0.2.5__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 (100) hide show
  1. {stackport-0.2.4/stackport.egg-info → stackport-0.2.5}/PKG-INFO +1 -1
  2. {stackport-0.2.4 → stackport-0.2.5}/backend/main.py +10 -9
  3. {stackport-0.2.4 → stackport-0.2.5}/backend/routes/dynamodb.py +158 -8
  4. stackport-0.2.5/backend/schemas/dynamodb.py +37 -0
  5. {stackport-0.2.4 → stackport-0.2.5}/pyproject.toml +1 -1
  6. {stackport-0.2.4 → stackport-0.2.5/stackport.egg-info}/PKG-INFO +1 -1
  7. {stackport-0.2.4 → stackport-0.2.5}/stackport.egg-info/SOURCES.txt +4 -4
  8. {stackport-0.2.4 → stackport-0.2.5}/tests/test_dynamodb_routes.py +154 -0
  9. {stackport-0.2.4 → stackport-0.2.5}/tests/test_readonly_middleware.py +20 -0
  10. stackport-0.2.5/ui/dist/assets/index-BXF-C5Pz.js +614 -0
  11. stackport-0.2.5/ui/dist/assets/index-MwTr-ebs.css +1 -0
  12. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/index.html +2 -2
  13. stackport-0.2.4/backend/schemas/dynamodb.py +0 -10
  14. stackport-0.2.4/ui/dist/assets/index-B2xjVeE-.js +0 -609
  15. stackport-0.2.4/ui/dist/assets/index-D_vb_J84.css +0 -1
  16. {stackport-0.2.4 → stackport-0.2.5}/LICENSE +0 -0
  17. {stackport-0.2.4 → stackport-0.2.5}/MANIFEST.in +0 -0
  18. {stackport-0.2.4 → stackport-0.2.5}/README.md +0 -0
  19. {stackport-0.2.4 → stackport-0.2.5}/backend/__init__.py +0 -0
  20. {stackport-0.2.4 → stackport-0.2.5}/backend/aws_client.py +0 -0
  21. {stackport-0.2.4 → stackport-0.2.5}/backend/cache.py +0 -0
  22. {stackport-0.2.4 → stackport-0.2.5}/backend/cli.py +0 -0
  23. {stackport-0.2.4 → stackport-0.2.5}/backend/config.py +0 -0
  24. {stackport-0.2.4 → stackport-0.2.5}/backend/routes/__init__.py +0 -0
  25. {stackport-0.2.4 → stackport-0.2.5}/backend/routes/common.py +0 -0
  26. {stackport-0.2.4 → stackport-0.2.5}/backend/routes/ec2.py +0 -0
  27. {stackport-0.2.4 → stackport-0.2.5}/backend/routes/endpoints.py +0 -0
  28. {stackport-0.2.4 → stackport-0.2.5}/backend/routes/iam.py +0 -0
  29. {stackport-0.2.4 → stackport-0.2.5}/backend/routes/lambda_svc.py +0 -0
  30. {stackport-0.2.4 → stackport-0.2.5}/backend/routes/logs.py +0 -0
  31. {stackport-0.2.4 → stackport-0.2.5}/backend/routes/resources.py +0 -0
  32. {stackport-0.2.4 → stackport-0.2.5}/backend/routes/s3.py +0 -0
  33. {stackport-0.2.4 → stackport-0.2.5}/backend/routes/secretsmanager.py +0 -0
  34. {stackport-0.2.4 → stackport-0.2.5}/backend/routes/sqs.py +0 -0
  35. {stackport-0.2.4 → stackport-0.2.5}/backend/routes/stats.py +0 -0
  36. {stackport-0.2.4 → stackport-0.2.5}/backend/routes/tags.py +0 -0
  37. {stackport-0.2.4 → stackport-0.2.5}/backend/schemas/__init__.py +0 -0
  38. {stackport-0.2.4 → stackport-0.2.5}/backend/schemas/s3.py +0 -0
  39. {stackport-0.2.4 → stackport-0.2.5}/backend/schemas/sqs.py +0 -0
  40. {stackport-0.2.4 → stackport-0.2.5}/backend/schemas/tags.py +0 -0
  41. {stackport-0.2.4 → stackport-0.2.5}/backend/websocket.py +0 -0
  42. {stackport-0.2.4 → stackport-0.2.5}/setup.cfg +0 -0
  43. {stackport-0.2.4 → stackport-0.2.5}/stackport.egg-info/dependency_links.txt +0 -0
  44. {stackport-0.2.4 → stackport-0.2.5}/stackport.egg-info/entry_points.txt +0 -0
  45. {stackport-0.2.4 → stackport-0.2.5}/stackport.egg-info/requires.txt +0 -0
  46. {stackport-0.2.4 → stackport-0.2.5}/stackport.egg-info/top_level.txt +0 -0
  47. {stackport-0.2.4 → stackport-0.2.5}/tests/test_cache.py +0 -0
  48. {stackport-0.2.4 → stackport-0.2.5}/tests/test_cli.py +0 -0
  49. {stackport-0.2.4 → stackport-0.2.5}/tests/test_client.py +0 -0
  50. {stackport-0.2.4 → stackport-0.2.5}/tests/test_config.py +0 -0
  51. {stackport-0.2.4 → stackport-0.2.5}/tests/test_ec2_routes.py +0 -0
  52. {stackport-0.2.4 → stackport-0.2.5}/tests/test_endpoints.py +0 -0
  53. {stackport-0.2.4 → stackport-0.2.5}/tests/test_iam_routes.py +0 -0
  54. {stackport-0.2.4 → stackport-0.2.5}/tests/test_lambda_routes.py +0 -0
  55. {stackport-0.2.4 → stackport-0.2.5}/tests/test_logs_routes.py +0 -0
  56. {stackport-0.2.4 → stackport-0.2.5}/tests/test_multi_endpoint.py +0 -0
  57. {stackport-0.2.4 → stackport-0.2.5}/tests/test_registries.py +0 -0
  58. {stackport-0.2.4 → stackport-0.2.5}/tests/test_routes.py +0 -0
  59. {stackport-0.2.4 → stackport-0.2.5}/tests/test_s3_routes.py +0 -0
  60. {stackport-0.2.4 → stackport-0.2.5}/tests/test_s3_upload_limit_env.py +0 -0
  61. {stackport-0.2.4 → stackport-0.2.5}/tests/test_secretsmanager_routes.py +0 -0
  62. {stackport-0.2.4 → stackport-0.2.5}/tests/test_sqs_routes.py +0 -0
  63. {stackport-0.2.4 → stackport-0.2.5}/tests/test_tags_routes.py +0 -0
  64. {stackport-0.2.4 → stackport-0.2.5}/tests/test_websocket.py +0 -0
  65. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/acm.svg +0 -0
  66. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/apigateway.svg +0 -0
  67. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/appsync.svg +0 -0
  68. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/athena.svg +0 -0
  69. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/cloudformation.svg +0 -0
  70. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/cloudfront.svg +0 -0
  71. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/cognito-idp.svg +0 -0
  72. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/dynamodb.svg +0 -0
  73. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/ec2.svg +0 -0
  74. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/ecr.svg +0 -0
  75. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/ecs.svg +0 -0
  76. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/elasticache.svg +0 -0
  77. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
  78. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
  79. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
  80. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/events.svg +0 -0
  81. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/firehose.svg +0 -0
  82. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/glue.svg +0 -0
  83. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/iam.svg +0 -0
  84. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/kinesis.svg +0 -0
  85. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/kms.svg +0 -0
  86. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/lambda.svg +0 -0
  87. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/logs.svg +0 -0
  88. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/monitoring.svg +0 -0
  89. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/rds.svg +0 -0
  90. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/route53.svg +0 -0
  91. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/s3.svg +0 -0
  92. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/secretsmanager.svg +0 -0
  93. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/ses.svg +0 -0
  94. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/sns.svg +0 -0
  95. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/sqs.svg +0 -0
  96. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/ssm.svg +0 -0
  97. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/stepfunctions.svg +0 -0
  98. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/aws-icons/wafv2.svg +0 -0
  99. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/favicon.png +0 -0
  100. {stackport-0.2.4 → stackport-0.2.5}/ui/dist/favicon.svg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackport
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: Universal AWS resource browser for local emulators
5
5
  Author: Davi Reis Vieira
6
6
  License: MIT
@@ -58,11 +58,14 @@ class ReadOnlyMiddleware(BaseHTTPMiddleware):
58
58
 
59
59
  WRITE_METHODS = {"POST", "PUT", "DELETE", "PATCH"}
60
60
 
61
- # POST endpoints that are read-only (query/invoke operations)
62
- READ_ONLY_POST_PATTERNS = (
63
- "/api/dynamodb/tables/", # /tables/{name}/query
64
- "/api/lambda/functions/", # /functions/{name}/invoke
65
- )
61
+ @staticmethod
62
+ def _is_read_only_post(path: str) -> bool:
63
+ """POST routes that are reads (not writes). Must not use path prefixes alone — e.g. item CRUD also lives under /api/dynamodb/tables/."""
64
+ if path.startswith("/api/dynamodb/tables/") and path.endswith("/query"):
65
+ return True
66
+ if path.startswith("/api/lambda/functions/") and path.endswith("/invoke"):
67
+ return True
68
+ return False
66
69
 
67
70
  async def dispatch(self, request: Request, call_next):
68
71
  if STACKPORT_ALLOW_WRITES:
@@ -74,10 +77,8 @@ class ReadOnlyMiddleware(BaseHTTPMiddleware):
74
77
 
75
78
  # Allow read-only POST operations (query, invoke)
76
79
  path = request.url.path
77
- if request.method == "POST":
78
- if any(path.startswith(p) for p in self.READ_ONLY_POST_PATTERNS):
79
- # These are read operations that happen to use POST
80
- return await call_next(request)
80
+ if request.method == "POST" and self._is_read_only_post(path):
81
+ return await call_next(request)
81
82
 
82
83
  # Block all write operations
83
84
  return JSONResponse(
@@ -1,15 +1,75 @@
1
+ import base64
2
+ import json
1
3
  import logging
2
4
  from typing import Any
3
5
 
4
- from fastapi import APIRouter, Depends, Query
6
+ from boto3.dynamodb.types import TypeSerializer
7
+ from botocore.exceptions import ClientError
8
+ from fastapi import APIRouter, Depends, HTTPException, Query
5
9
 
6
10
  from backend.aws_client import get_client
7
11
  from backend.cache import cache
8
12
  from backend.routes.common import get_endpoint_url
9
- from backend.schemas.dynamodb import QueryRequest
13
+ from backend.schemas.dynamodb import BatchWriteRequest, DeleteItemRequest, PutItemRequest, QueryRequest
10
14
 
11
15
  logger = logging.getLogger(__name__)
12
16
 
17
+ _serializer = TypeSerializer()
18
+
19
+
20
+ def _plain_to_dynamodb_item(plain: dict[str, Any]) -> dict[str, Any]:
21
+ return {k: _serializer.serialize(v) for k, v in plain.items()}
22
+
23
+
24
+ def _get_partition_sort_keys(dynamodb: Any, table_name: str) -> tuple[str | None, str | None]:
25
+ table_resp = dynamodb.describe_table(TableName=table_name)
26
+ key_schema = table_resp["Table"].get("KeySchema", [])
27
+ partition_key = next((k["AttributeName"] for k in key_schema if k["KeyType"] == "HASH"), None)
28
+ sort_key = next((k["AttributeName"] for k in key_schema if k["KeyType"] == "RANGE"), None)
29
+ return partition_key, sort_key
30
+
31
+
32
+ def _require_key_attributes(
33
+ attrs: dict[str, Any],
34
+ partition_key: str | None,
35
+ sort_key: str | None,
36
+ *,
37
+ label: str,
38
+ ) -> None:
39
+ if not partition_key:
40
+ raise HTTPException(status_code=400, detail="Table has no partition key in key schema")
41
+ if partition_key not in attrs:
42
+ raise HTTPException(status_code=400, detail=f"Missing {label} attribute: {partition_key!r}")
43
+ if sort_key and sort_key not in attrs:
44
+ raise HTTPException(status_code=400, detail=f"Missing {label} attribute: {sort_key!r}")
45
+
46
+
47
+ def _coerce_item_dict(raw: dict[str, Any], item_format: str) -> dict[str, Any]:
48
+ if item_format == "plain":
49
+ try:
50
+ return _plain_to_dynamodb_item(raw)
51
+ except TypeError as e:
52
+ raise HTTPException(status_code=400, detail=f"Could not convert plain item to DynamoDB types: {e}") from e
53
+ return raw
54
+
55
+
56
+ def _coerce_key_dict(raw: dict[str, Any], item_format: str) -> dict[str, Any]:
57
+ if item_format == "plain":
58
+ try:
59
+ return _plain_to_dynamodb_item(raw)
60
+ except TypeError as e:
61
+ raise HTTPException(status_code=400, detail=f"Could not convert plain key to DynamoDB types: {e}") from e
62
+ return raw
63
+
64
+
65
+ def _invalidate_table_item_count(table_name: str, endpoint_url: str | None) -> None:
66
+ cache.delete(f"{endpoint_url}:dynamodb:item_count:{table_name}")
67
+
68
+
69
+ def _client_error_message(exc: ClientError) -> str:
70
+ err = exc.response.get("Error", {})
71
+ return err.get("Message", err.get("Code", str(exc)))
72
+
13
73
  router = APIRouter()
14
74
 
15
75
 
@@ -121,9 +181,6 @@ def scan_table(
121
181
  }
122
182
 
123
183
  if exclusive_start_key:
124
- import base64
125
- import json
126
-
127
184
  try:
128
185
  decoded = base64.b64decode(exclusive_start_key).decode("utf-8")
129
186
  scan_params["ExclusiveStartKey"] = json.loads(decoded)
@@ -137,9 +194,6 @@ def scan_table(
137
194
  last_evaluated_key = resp.get("LastEvaluatedKey")
138
195
  next_token = None
139
196
  if last_evaluated_key:
140
- import base64
141
- import json
142
-
143
197
  next_token = base64.b64encode(json.dumps(last_evaluated_key).encode("utf-8")).decode("utf-8")
144
198
 
145
199
  return {
@@ -212,3 +266,99 @@ def query_table(name: str, request: QueryRequest, endpoint_url: str | None = Dep
212
266
  except Exception as e:
213
267
  logger.error("Failed to query table %s: %s", name, e, exc_info=True)
214
268
  return {"error": str(e), "items": [], "count": 0}
269
+
270
+
271
+ @router.api_route("/tables/{name}/items", methods=["POST", "PUT"])
272
+ def put_table_item(
273
+ name: str,
274
+ request: PutItemRequest,
275
+ endpoint_url: str | None = Depends(get_endpoint_url),
276
+ ) -> dict[str, Any]:
277
+ """Create or replace an item (DynamoDB PutItem)."""
278
+ dynamodb = get_client("dynamodb", endpoint_url)
279
+ try:
280
+ partition_key, sort_key = _get_partition_sort_keys(dynamodb, name)
281
+ except Exception as e:
282
+ logger.error("put_item describe failed for %s: %s", name, e, exc_info=True)
283
+ raise HTTPException(status_code=400, detail=f"Failed to read table: {e}") from e
284
+
285
+ item = _coerce_item_dict(request.item, request.item_format)
286
+ _require_key_attributes(item, partition_key, sort_key, label="item")
287
+
288
+ try:
289
+ dynamodb.put_item(TableName=name, Item=item)
290
+ except ClientError as e:
291
+ msg = _client_error_message(e)
292
+ logger.error("put_item %s: %s", name, e, exc_info=True)
293
+ raise HTTPException(status_code=400, detail=msg) from e
294
+
295
+ _invalidate_table_item_count(name, endpoint_url)
296
+ return {"ok": True, "table": name}
297
+
298
+
299
+ @router.delete("/tables/{name}/items")
300
+ def delete_table_item(
301
+ name: str,
302
+ request: DeleteItemRequest,
303
+ endpoint_url: str | None = Depends(get_endpoint_url),
304
+ ) -> dict[str, Any]:
305
+ dynamodb = get_client("dynamodb", endpoint_url)
306
+ try:
307
+ partition_key, sort_key = _get_partition_sort_keys(dynamodb, name)
308
+ except Exception as e:
309
+ raise HTTPException(status_code=400, detail=f"Failed to read table: {e}") from e
310
+
311
+ key = _coerce_key_dict(request.key, request.item_format)
312
+ _require_key_attributes(key, partition_key, sort_key, label="key")
313
+
314
+ try:
315
+ dynamodb.delete_item(TableName=name, Key=key)
316
+ except ClientError as e:
317
+ msg = _client_error_message(e)
318
+ raise HTTPException(status_code=400, detail=msg) from e
319
+
320
+ _invalidate_table_item_count(name, endpoint_url)
321
+ return {"ok": True, "table": name}
322
+
323
+
324
+ @router.post("/tables/{name}/items/batch")
325
+ def batch_write_items(
326
+ name: str,
327
+ request: BatchWriteRequest,
328
+ endpoint_url: str | None = Depends(get_endpoint_url),
329
+ ) -> dict[str, Any]:
330
+ dynamodb = get_client("dynamodb", endpoint_url)
331
+ try:
332
+ partition_key, sort_key = _get_partition_sort_keys(dynamodb, name)
333
+ except Exception as e:
334
+ raise HTTPException(status_code=400, detail=f"Failed to read table: {e}") from e
335
+
336
+ batch_requests: list[dict[str, Any]] = []
337
+ for op in request.operations:
338
+ if op.op == "put":
339
+ item = _coerce_item_dict(op.item, request.item_format)
340
+ _require_key_attributes(item, partition_key, sort_key, label="item")
341
+ batch_requests.append({"PutRequest": {"Item": item}})
342
+ else:
343
+ key = _coerce_key_dict(op.key, request.item_format)
344
+ _require_key_attributes(key, partition_key, sort_key, label="key")
345
+ batch_requests.append({"DeleteRequest": {"Key": key}})
346
+
347
+ try:
348
+ resp = dynamodb.batch_write_item(RequestItems={name: batch_requests})
349
+ except ClientError as e:
350
+ msg = _client_error_message(e)
351
+ raise HTTPException(status_code=400, detail=msg) from e
352
+
353
+ _invalidate_table_item_count(name, endpoint_url)
354
+
355
+ uproc = resp.get("UnprocessedItems", {})
356
+ if uproc:
357
+ return {
358
+ "ok": True,
359
+ "table": name,
360
+ "unprocessed": uproc,
361
+ "message": "Some items were not processed; retry with returned keys.",
362
+ }
363
+
364
+ return {"ok": True, "table": name, "unprocessed": {}}
@@ -0,0 +1,37 @@
1
+ """Pydantic schemas for DynamoDB API requests."""
2
+
3
+ from typing import Any, Literal, Union
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class QueryRequest(BaseModel):
9
+ partition_key_value: str
10
+ sort_key_value: str | None = None
11
+ sort_key_operator: str = "=" # =, <, <=, >, >=, BETWEEN, BEGINS_WITH
12
+ limit: int = 25
13
+
14
+
15
+ class PutItemRequest(BaseModel):
16
+ item: dict[str, Any]
17
+ item_format: Literal["dynamodb", "plain"] = "dynamodb"
18
+
19
+
20
+ class DeleteItemRequest(BaseModel):
21
+ key: dict[str, Any]
22
+ item_format: Literal["dynamodb", "plain"] = "dynamodb"
23
+
24
+
25
+ class _BatchOpPut(BaseModel):
26
+ op: Literal["put"] = "put"
27
+ item: dict[str, Any]
28
+
29
+
30
+ class _BatchOpDelete(BaseModel):
31
+ op: Literal["delete"] = "delete"
32
+ key: dict[str, Any]
33
+
34
+
35
+ class BatchWriteRequest(BaseModel):
36
+ item_format: Literal["dynamodb", "plain"] = "dynamodb"
37
+ operations: list[Union[_BatchOpPut, _BatchOpDelete]] = Field(min_length=1, max_length=25)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackport"
3
- version = "0.2.4"
3
+ version = "0.2.5"
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.2.4
3
+ Version: 0.2.5
4
4
  Summary: Universal AWS resource browser for local emulators
5
5
  Author: Davi Reis Vieira
6
6
  License: MIT
@@ -12,8 +12,8 @@ backend/websocket.py
12
12
  backend/../ui/dist/favicon.png
13
13
  backend/../ui/dist/favicon.svg
14
14
  backend/../ui/dist/index.html
15
- backend/../ui/dist/assets/index-B2xjVeE-.js
16
- backend/../ui/dist/assets/index-D_vb_J84.css
15
+ backend/../ui/dist/assets/index-BXF-C5Pz.js
16
+ backend/../ui/dist/assets/index-MwTr-ebs.css
17
17
  backend/../ui/dist/aws-icons/acm.svg
18
18
  backend/../ui/dist/aws-icons/apigateway.svg
19
19
  backend/../ui/dist/aws-icons/appsync.svg
@@ -96,8 +96,8 @@ tests/test_websocket.py
96
96
  ui/dist/favicon.png
97
97
  ui/dist/favicon.svg
98
98
  ui/dist/index.html
99
- ui/dist/assets/index-B2xjVeE-.js
100
- ui/dist/assets/index-D_vb_J84.css
99
+ ui/dist/assets/index-BXF-C5Pz.js
100
+ ui/dist/assets/index-MwTr-ebs.css
101
101
  ui/dist/aws-icons/acm.svg
102
102
  ui/dist/aws-icons/apigateway.svg
103
103
  ui/dist/aws-icons/appsync.svg
@@ -253,3 +253,157 @@ class TestQueryTable:
253
253
  # Verify the query used begins_with
254
254
  call_kwargs = mock_ddb.query.call_args[1]
255
255
  assert "begins_with" in call_kwargs["KeyConditionExpression"]
256
+
257
+
258
+ class TestItemWrites:
259
+ @patch("backend.routes.dynamodb.get_client")
260
+ def test_put_item_dynamodb(self, mock_get_client):
261
+ mock_ddb = MagicMock()
262
+ mock_get_client.return_value = mock_ddb
263
+ mock_ddb.describe_table.return_value = {
264
+ "Table": {
265
+ "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
266
+ "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
267
+ }
268
+ }
269
+
270
+ resp = client.put(
271
+ "/api/dynamodb/tables/t1/items",
272
+ json={"item": {"pk": {"S": "a"}, "name": {"S": "X"}}, "item_format": "dynamodb"},
273
+ )
274
+ assert resp.status_code == 200
275
+ assert resp.json()["ok"] is True
276
+ mock_ddb.put_item.assert_called_once()
277
+ call_kw = mock_ddb.put_item.call_args[1]
278
+ assert call_kw["TableName"] == "t1"
279
+ assert call_kw["Item"]["pk"]["S"] == "a"
280
+
281
+ @patch("backend.routes.dynamodb.get_client")
282
+ def test_put_item_plain(self, mock_get_client):
283
+ mock_ddb = MagicMock()
284
+ mock_get_client.return_value = mock_ddb
285
+ mock_ddb.describe_table.return_value = {
286
+ "Table": {
287
+ "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
288
+ "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
289
+ }
290
+ }
291
+
292
+ resp = client.post(
293
+ "/api/dynamodb/tables/t1/items",
294
+ json={"item": {"pk": "hello", "n": 42}, "item_format": "plain"},
295
+ )
296
+ assert resp.status_code == 200
297
+ it = mock_ddb.put_item.call_args[1]["Item"]
298
+ assert it["pk"]["S"] == "hello"
299
+ assert it["n"]["N"] == "42"
300
+
301
+ @patch("backend.routes.dynamodb.get_client")
302
+ def test_put_item_missing_key_returns_400(self, mock_get_client):
303
+ mock_ddb = MagicMock()
304
+ mock_get_client.return_value = mock_ddb
305
+ mock_ddb.describe_table.return_value = {
306
+ "Table": {
307
+ "KeySchema": [
308
+ {"AttributeName": "pk", "KeyType": "HASH"},
309
+ {"AttributeName": "sk", "KeyType": "RANGE"},
310
+ ],
311
+ "AttributeDefinitions": [
312
+ {"AttributeName": "pk", "AttributeType": "S"},
313
+ {"AttributeName": "sk", "AttributeType": "S"},
314
+ ],
315
+ }
316
+ }
317
+
318
+ resp = client.post(
319
+ "/api/dynamodb/tables/t1/items",
320
+ json={"item": {"pk": {"S": "a"}}, "item_format": "dynamodb"},
321
+ )
322
+ assert resp.status_code == 400
323
+ assert "sort" in resp.json()["detail"].lower() or "sk" in resp.json()["detail"].lower()
324
+
325
+ @patch("backend.routes.dynamodb.get_client")
326
+ def test_delete_item(self, mock_get_client):
327
+ mock_ddb = MagicMock()
328
+ mock_get_client.return_value = mock_ddb
329
+ mock_ddb.describe_table.return_value = {
330
+ "Table": {
331
+ "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
332
+ "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
333
+ }
334
+ }
335
+
336
+ resp = client.request(
337
+ "DELETE",
338
+ "/api/dynamodb/tables/t1/items",
339
+ json={"key": {"pk": {"S": "a"}}, "item_format": "dynamodb"},
340
+ )
341
+ assert resp.status_code == 200
342
+ call_kw = mock_ddb.delete_item.call_args[1]
343
+ assert call_kw["Key"]["pk"]["S"] == "a"
344
+ assert call_kw["TableName"] == "t1"
345
+
346
+ @patch("backend.routes.dynamodb.get_client")
347
+ def test_batch_write(self, mock_get_client):
348
+ mock_ddb = MagicMock()
349
+ mock_get_client.return_value = mock_ddb
350
+ mock_ddb.describe_table.return_value = {
351
+ "Table": {
352
+ "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
353
+ "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
354
+ }
355
+ }
356
+ mock_ddb.batch_write_item.return_value = {"UnprocessedItems": {}}
357
+
358
+ resp = client.post(
359
+ "/api/dynamodb/tables/t1/items/batch",
360
+ json={
361
+ "item_format": "dynamodb",
362
+ "operations": [
363
+ {"op": "put", "item": {"pk": {"S": "1"}}},
364
+ {"op": "delete", "key": {"pk": {"S": "2"}}},
365
+ ],
366
+ },
367
+ )
368
+ assert resp.status_code == 200
369
+ req = mock_ddb.batch_write_item.call_args[1]["RequestItems"]
370
+ assert "t1" in req
371
+ assert len(req["t1"]) == 2
372
+ assert "PutRequest" in req["t1"][0]
373
+ assert "DeleteRequest" in req["t1"][1]
374
+
375
+ @patch("backend.routes.dynamodb.cache.delete")
376
+ @patch("backend.routes.dynamodb.get_client")
377
+ def test_batch_write_partial_invalidates_cache(self, mock_get_client, mock_cache_delete):
378
+ """Cache must be invalidated even when some items are unprocessed,
379
+ because items NOT listed in UnprocessedItems were already written."""
380
+ mock_ddb = MagicMock()
381
+ mock_get_client.return_value = mock_ddb
382
+ mock_ddb.describe_table.return_value = {
383
+ "Table": {
384
+ "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
385
+ "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
386
+ }
387
+ }
388
+ unprocessed = {"t1": [{"PutRequest": {"Item": {"pk": {"S": "1"}}}}]}
389
+ mock_ddb.batch_write_item.return_value = {"UnprocessedItems": unprocessed}
390
+
391
+ resp = client.post(
392
+ "/api/dynamodb/tables/t1/items/batch",
393
+ json={
394
+ "item_format": "dynamodb",
395
+ "operations": [
396
+ {"op": "put", "item": {"pk": {"S": "1"}}},
397
+ {"op": "put", "item": {"pk": {"S": "2"}}},
398
+ ],
399
+ },
400
+ )
401
+ assert resp.status_code == 200
402
+ body = resp.json()
403
+ assert body["unprocessed"] == unprocessed
404
+ assert "message" in body
405
+
406
+ cache_keys = [c.args[0] for c in mock_cache_delete.call_args_list]
407
+ assert any(k.endswith(":dynamodb:item_count:t1") for k in cache_keys), (
408
+ f"expected item_count cache invalidation, got calls: {cache_keys}"
409
+ )
@@ -88,6 +88,26 @@ class TestReadOnlyMiddleware:
88
88
  # Should NOT be blocked by middleware
89
89
  assert resp.status_code != 403
90
90
 
91
+ def test_dynamodb_item_write_post_blocked_when_writes_disabled(self):
92
+ """POST to item write paths must be blocked (not whitelisted like /query)."""
93
+ with patch("backend.main.STACKPORT_ALLOW_WRITES", False):
94
+ from backend.main import app
95
+
96
+ client = TestClient(app, raise_server_exceptions=False)
97
+
98
+ resp = client.post(
99
+ "/api/dynamodb/tables/t/items",
100
+ json={"item": {"pk": {"S": "a"}}, "item_format": "dynamodb"},
101
+ )
102
+ assert resp.status_code == 403
103
+ assert "disabled" in resp.json()["detail"].lower()
104
+
105
+ resp = client.post(
106
+ "/api/dynamodb/tables/t/items/batch",
107
+ json={"requests": []},
108
+ )
109
+ assert resp.status_code == 403
110
+
91
111
  def test_writes_allowed_when_enabled(self):
92
112
  """All write operations work when STACKPORT_ALLOW_WRITES=true."""
93
113
  with patch("backend.main.STACKPORT_ALLOW_WRITES", True):