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.
- {stackport-0.2.4/stackport.egg-info → stackport-0.2.6}/PKG-INFO +1 -1
- {stackport-0.2.4 → stackport-0.2.6}/backend/main.py +10 -9
- {stackport-0.2.4 → stackport-0.2.6}/backend/routes/dynamodb.py +158 -8
- stackport-0.2.6/backend/routes/s3.py +666 -0
- stackport-0.2.6/backend/schemas/dynamodb.py +37 -0
- stackport-0.2.6/backend/schemas/s3.py +103 -0
- {stackport-0.2.4 → stackport-0.2.6}/pyproject.toml +1 -1
- {stackport-0.2.4 → stackport-0.2.6/stackport.egg-info}/PKG-INFO +1 -1
- {stackport-0.2.4 → stackport-0.2.6}/stackport.egg-info/SOURCES.txt +4 -4
- {stackport-0.2.4 → stackport-0.2.6}/tests/test_dynamodb_routes.py +154 -0
- {stackport-0.2.4 → stackport-0.2.6}/tests/test_readonly_middleware.py +20 -0
- {stackport-0.2.4 → stackport-0.2.6}/tests/test_s3_routes.py +228 -0
- stackport-0.2.6/ui/dist/assets/index-C-DyQfZs.css +1 -0
- stackport-0.2.6/ui/dist/assets/index-DYRN3E1e.js +614 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/index.html +2 -2
- stackport-0.2.4/backend/routes/s3.py +0 -343
- stackport-0.2.4/backend/schemas/dynamodb.py +0 -10
- stackport-0.2.4/backend/schemas/s3.py +0 -30
- stackport-0.2.4/ui/dist/assets/index-B2xjVeE-.js +0 -609
- stackport-0.2.4/ui/dist/assets/index-D_vb_J84.css +0 -1
- {stackport-0.2.4 → stackport-0.2.6}/LICENSE +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/MANIFEST.in +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/README.md +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/backend/__init__.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/backend/aws_client.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/backend/cache.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/backend/cli.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/backend/config.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/backend/routes/__init__.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/backend/routes/common.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/backend/routes/ec2.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/backend/routes/endpoints.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/backend/routes/iam.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/backend/routes/lambda_svc.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/backend/routes/logs.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/backend/routes/resources.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/backend/routes/secretsmanager.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/backend/routes/sqs.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/backend/routes/stats.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/backend/routes/tags.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/backend/schemas/__init__.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/backend/schemas/sqs.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/backend/schemas/tags.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/backend/websocket.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/setup.cfg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/stackport.egg-info/dependency_links.txt +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/stackport.egg-info/entry_points.txt +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/stackport.egg-info/requires.txt +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/stackport.egg-info/top_level.txt +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/tests/test_cache.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/tests/test_cli.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/tests/test_client.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/tests/test_config.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/tests/test_ec2_routes.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/tests/test_endpoints.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/tests/test_iam_routes.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/tests/test_lambda_routes.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/tests/test_logs_routes.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/tests/test_multi_endpoint.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/tests/test_registries.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/tests/test_routes.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/tests/test_s3_upload_limit_env.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/tests/test_secretsmanager_routes.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/tests/test_sqs_routes.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/tests/test_tags_routes.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/tests/test_websocket.py +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/acm.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/apigateway.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/appsync.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/athena.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/cloudformation.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/cloudfront.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/cognito-idp.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/dynamodb.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/ec2.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/ecr.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/ecs.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/elasticache.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/events.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/firehose.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/glue.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/iam.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/kinesis.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/kms.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/lambda.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/logs.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/monitoring.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/rds.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/route53.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/s3.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/secretsmanager.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/ses.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/sns.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/sqs.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/ssm.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/stepfunctions.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/aws-icons/wafv2.svg +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/favicon.png +0 -0
- {stackport-0.2.4 → stackport-0.2.6}/ui/dist/favicon.svg +0 -0
|
@@ -58,11 +58,14 @@ class ReadOnlyMiddleware(BaseHTTPMiddleware):
|
|
|
58
58
|
|
|
59
59
|
WRITE_METHODS = {"POST", "PUT", "DELETE", "PATCH"}
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"/api/dynamodb/tables
|
|
64
|
-
"/api/
|
|
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
|
-
|
|
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
|
|
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": {}}
|