stackport 0.2.4__tar.gz → 0.2.6__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 (102) hide show
  1. {stackport-0.2.4/stackport.egg-info → stackport-0.2.6}/PKG-INFO +1 -1
  2. {stackport-0.2.4 → stackport-0.2.6}/backend/main.py +10 -9
  3. {stackport-0.2.4 → stackport-0.2.6}/backend/routes/dynamodb.py +158 -8
  4. stackport-0.2.6/backend/routes/s3.py +666 -0
  5. stackport-0.2.6/backend/schemas/dynamodb.py +37 -0
  6. stackport-0.2.6/backend/schemas/s3.py +103 -0
  7. {stackport-0.2.4 → stackport-0.2.6}/pyproject.toml +1 -1
  8. {stackport-0.2.4 → stackport-0.2.6/stackport.egg-info}/PKG-INFO +1 -1
  9. {stackport-0.2.4 → stackport-0.2.6}/stackport.egg-info/SOURCES.txt +4 -4
  10. {stackport-0.2.4 → stackport-0.2.6}/tests/test_dynamodb_routes.py +154 -0
  11. {stackport-0.2.4 → stackport-0.2.6}/tests/test_readonly_middleware.py +20 -0
  12. {stackport-0.2.4 → stackport-0.2.6}/tests/test_s3_routes.py +228 -0
  13. stackport-0.2.6/ui/dist/assets/index-C-DyQfZs.css +1 -0
  14. stackport-0.2.6/ui/dist/assets/index-DYRN3E1e.js +614 -0
  15. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/index.html +2 -2
  16. stackport-0.2.4/backend/routes/s3.py +0 -343
  17. stackport-0.2.4/backend/schemas/dynamodb.py +0 -10
  18. stackport-0.2.4/backend/schemas/s3.py +0 -30
  19. stackport-0.2.4/ui/dist/assets/index-B2xjVeE-.js +0 -609
  20. stackport-0.2.4/ui/dist/assets/index-D_vb_J84.css +0 -1
  21. {stackport-0.2.4 → stackport-0.2.6}/LICENSE +0 -0
  22. {stackport-0.2.4 → stackport-0.2.6}/MANIFEST.in +0 -0
  23. {stackport-0.2.4 → stackport-0.2.6}/README.md +0 -0
  24. {stackport-0.2.4 → stackport-0.2.6}/backend/__init__.py +0 -0
  25. {stackport-0.2.4 → stackport-0.2.6}/backend/aws_client.py +0 -0
  26. {stackport-0.2.4 → stackport-0.2.6}/backend/cache.py +0 -0
  27. {stackport-0.2.4 → stackport-0.2.6}/backend/cli.py +0 -0
  28. {stackport-0.2.4 → stackport-0.2.6}/backend/config.py +0 -0
  29. {stackport-0.2.4 → stackport-0.2.6}/backend/routes/__init__.py +0 -0
  30. {stackport-0.2.4 → stackport-0.2.6}/backend/routes/common.py +0 -0
  31. {stackport-0.2.4 → stackport-0.2.6}/backend/routes/ec2.py +0 -0
  32. {stackport-0.2.4 → stackport-0.2.6}/backend/routes/endpoints.py +0 -0
  33. {stackport-0.2.4 → stackport-0.2.6}/backend/routes/iam.py +0 -0
  34. {stackport-0.2.4 → stackport-0.2.6}/backend/routes/lambda_svc.py +0 -0
  35. {stackport-0.2.4 → stackport-0.2.6}/backend/routes/logs.py +0 -0
  36. {stackport-0.2.4 → stackport-0.2.6}/backend/routes/resources.py +0 -0
  37. {stackport-0.2.4 → stackport-0.2.6}/backend/routes/secretsmanager.py +0 -0
  38. {stackport-0.2.4 → stackport-0.2.6}/backend/routes/sqs.py +0 -0
  39. {stackport-0.2.4 → stackport-0.2.6}/backend/routes/stats.py +0 -0
  40. {stackport-0.2.4 → stackport-0.2.6}/backend/routes/tags.py +0 -0
  41. {stackport-0.2.4 → stackport-0.2.6}/backend/schemas/__init__.py +0 -0
  42. {stackport-0.2.4 → stackport-0.2.6}/backend/schemas/sqs.py +0 -0
  43. {stackport-0.2.4 → stackport-0.2.6}/backend/schemas/tags.py +0 -0
  44. {stackport-0.2.4 → stackport-0.2.6}/backend/websocket.py +0 -0
  45. {stackport-0.2.4 → stackport-0.2.6}/setup.cfg +0 -0
  46. {stackport-0.2.4 → stackport-0.2.6}/stackport.egg-info/dependency_links.txt +0 -0
  47. {stackport-0.2.4 → stackport-0.2.6}/stackport.egg-info/entry_points.txt +0 -0
  48. {stackport-0.2.4 → stackport-0.2.6}/stackport.egg-info/requires.txt +0 -0
  49. {stackport-0.2.4 → stackport-0.2.6}/stackport.egg-info/top_level.txt +0 -0
  50. {stackport-0.2.4 → stackport-0.2.6}/tests/test_cache.py +0 -0
  51. {stackport-0.2.4 → stackport-0.2.6}/tests/test_cli.py +0 -0
  52. {stackport-0.2.4 → stackport-0.2.6}/tests/test_client.py +0 -0
  53. {stackport-0.2.4 → stackport-0.2.6}/tests/test_config.py +0 -0
  54. {stackport-0.2.4 → stackport-0.2.6}/tests/test_ec2_routes.py +0 -0
  55. {stackport-0.2.4 → stackport-0.2.6}/tests/test_endpoints.py +0 -0
  56. {stackport-0.2.4 → stackport-0.2.6}/tests/test_iam_routes.py +0 -0
  57. {stackport-0.2.4 → stackport-0.2.6}/tests/test_lambda_routes.py +0 -0
  58. {stackport-0.2.4 → stackport-0.2.6}/tests/test_logs_routes.py +0 -0
  59. {stackport-0.2.4 → stackport-0.2.6}/tests/test_multi_endpoint.py +0 -0
  60. {stackport-0.2.4 → stackport-0.2.6}/tests/test_registries.py +0 -0
  61. {stackport-0.2.4 → stackport-0.2.6}/tests/test_routes.py +0 -0
  62. {stackport-0.2.4 → stackport-0.2.6}/tests/test_s3_upload_limit_env.py +0 -0
  63. {stackport-0.2.4 → stackport-0.2.6}/tests/test_secretsmanager_routes.py +0 -0
  64. {stackport-0.2.4 → stackport-0.2.6}/tests/test_sqs_routes.py +0 -0
  65. {stackport-0.2.4 → stackport-0.2.6}/tests/test_tags_routes.py +0 -0
  66. {stackport-0.2.4 → stackport-0.2.6}/tests/test_websocket.py +0 -0
  67. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/acm.svg +0 -0
  68. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/apigateway.svg +0 -0
  69. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/appsync.svg +0 -0
  70. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/athena.svg +0 -0
  71. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/cloudformation.svg +0 -0
  72. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/cloudfront.svg +0 -0
  73. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/cognito-idp.svg +0 -0
  74. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/dynamodb.svg +0 -0
  75. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/ec2.svg +0 -0
  76. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/ecr.svg +0 -0
  77. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/ecs.svg +0 -0
  78. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/elasticache.svg +0 -0
  79. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
  80. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
  81. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
  82. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/events.svg +0 -0
  83. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/firehose.svg +0 -0
  84. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/glue.svg +0 -0
  85. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/iam.svg +0 -0
  86. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/kinesis.svg +0 -0
  87. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/kms.svg +0 -0
  88. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/lambda.svg +0 -0
  89. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/logs.svg +0 -0
  90. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/monitoring.svg +0 -0
  91. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/rds.svg +0 -0
  92. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/route53.svg +0 -0
  93. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/s3.svg +0 -0
  94. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/secretsmanager.svg +0 -0
  95. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/ses.svg +0 -0
  96. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/sns.svg +0 -0
  97. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/sqs.svg +0 -0
  98. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/ssm.svg +0 -0
  99. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/stepfunctions.svg +0 -0
  100. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/wafv2.svg +0 -0
  101. {stackport-0.2.4 → stackport-0.2.6}/ui/dist/favicon.png +0 -0
  102. {stackport-0.2.4 → stackport-0.2.6}/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.6
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": {}}