stackport 0.2.1__tar.gz → 0.2.2__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 (101) hide show
  1. {stackport-0.2.1/stackport.egg-info → stackport-0.2.2}/PKG-INFO +1 -1
  2. {stackport-0.2.1 → stackport-0.2.2}/backend/routes/dynamodb.py +1 -7
  3. {stackport-0.2.1 → stackport-0.2.2}/backend/routes/s3.py +1 -28
  4. stackport-0.2.2/backend/routes/sqs.py +575 -0
  5. {stackport-0.2.1 → stackport-0.2.2}/backend/routes/tags.py +1 -15
  6. stackport-0.2.2/backend/schemas/__init__.py +11 -0
  7. stackport-0.2.2/backend/schemas/dynamodb.py +10 -0
  8. stackport-0.2.2/backend/schemas/s3.py +30 -0
  9. stackport-0.2.2/backend/schemas/sqs.py +80 -0
  10. stackport-0.2.2/backend/schemas/tags.py +17 -0
  11. {stackport-0.2.1 → stackport-0.2.2}/pyproject.toml +1 -1
  12. {stackport-0.2.1 → stackport-0.2.2/stackport.egg-info}/PKG-INFO +1 -1
  13. {stackport-0.2.1 → stackport-0.2.2}/stackport.egg-info/SOURCES.txt +9 -4
  14. stackport-0.2.2/tests/test_sqs_routes.py +776 -0
  15. stackport-0.2.2/ui/dist/assets/index-B2xjVeE-.js +609 -0
  16. stackport-0.2.2/ui/dist/assets/index-D_vb_J84.css +1 -0
  17. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/index.html +2 -2
  18. stackport-0.2.1/backend/routes/sqs.py +0 -308
  19. stackport-0.2.1/tests/test_sqs_routes.py +0 -290
  20. stackport-0.2.1/ui/dist/assets/index-BNpVm9Z9.js +0 -568
  21. stackport-0.2.1/ui/dist/assets/index-CgkBNKzX.css +0 -1
  22. {stackport-0.2.1 → stackport-0.2.2}/LICENSE +0 -0
  23. {stackport-0.2.1 → stackport-0.2.2}/MANIFEST.in +0 -0
  24. {stackport-0.2.1 → stackport-0.2.2}/README.md +0 -0
  25. {stackport-0.2.1 → stackport-0.2.2}/backend/__init__.py +0 -0
  26. {stackport-0.2.1 → stackport-0.2.2}/backend/aws_client.py +0 -0
  27. {stackport-0.2.1 → stackport-0.2.2}/backend/cache.py +0 -0
  28. {stackport-0.2.1 → stackport-0.2.2}/backend/cli.py +0 -0
  29. {stackport-0.2.1 → stackport-0.2.2}/backend/config.py +0 -0
  30. {stackport-0.2.1 → stackport-0.2.2}/backend/main.py +0 -0
  31. {stackport-0.2.1 → stackport-0.2.2}/backend/routes/__init__.py +0 -0
  32. {stackport-0.2.1 → stackport-0.2.2}/backend/routes/common.py +0 -0
  33. {stackport-0.2.1 → stackport-0.2.2}/backend/routes/ec2.py +0 -0
  34. {stackport-0.2.1 → stackport-0.2.2}/backend/routes/endpoints.py +0 -0
  35. {stackport-0.2.1 → stackport-0.2.2}/backend/routes/iam.py +0 -0
  36. {stackport-0.2.1 → stackport-0.2.2}/backend/routes/lambda_svc.py +0 -0
  37. {stackport-0.2.1 → stackport-0.2.2}/backend/routes/logs.py +0 -0
  38. {stackport-0.2.1 → stackport-0.2.2}/backend/routes/resources.py +0 -0
  39. {stackport-0.2.1 → stackport-0.2.2}/backend/routes/secretsmanager.py +0 -0
  40. {stackport-0.2.1 → stackport-0.2.2}/backend/routes/stats.py +0 -0
  41. {stackport-0.2.1 → stackport-0.2.2}/backend/websocket.py +0 -0
  42. {stackport-0.2.1 → stackport-0.2.2}/setup.cfg +0 -0
  43. {stackport-0.2.1 → stackport-0.2.2}/stackport.egg-info/dependency_links.txt +0 -0
  44. {stackport-0.2.1 → stackport-0.2.2}/stackport.egg-info/entry_points.txt +0 -0
  45. {stackport-0.2.1 → stackport-0.2.2}/stackport.egg-info/requires.txt +0 -0
  46. {stackport-0.2.1 → stackport-0.2.2}/stackport.egg-info/top_level.txt +0 -0
  47. {stackport-0.2.1 → stackport-0.2.2}/tests/test_cache.py +0 -0
  48. {stackport-0.2.1 → stackport-0.2.2}/tests/test_cli.py +0 -0
  49. {stackport-0.2.1 → stackport-0.2.2}/tests/test_client.py +0 -0
  50. {stackport-0.2.1 → stackport-0.2.2}/tests/test_config.py +0 -0
  51. {stackport-0.2.1 → stackport-0.2.2}/tests/test_dynamodb_routes.py +0 -0
  52. {stackport-0.2.1 → stackport-0.2.2}/tests/test_ec2_routes.py +0 -0
  53. {stackport-0.2.1 → stackport-0.2.2}/tests/test_endpoints.py +0 -0
  54. {stackport-0.2.1 → stackport-0.2.2}/tests/test_iam_routes.py +0 -0
  55. {stackport-0.2.1 → stackport-0.2.2}/tests/test_lambda_routes.py +0 -0
  56. {stackport-0.2.1 → stackport-0.2.2}/tests/test_logs_routes.py +0 -0
  57. {stackport-0.2.1 → stackport-0.2.2}/tests/test_multi_endpoint.py +0 -0
  58. {stackport-0.2.1 → stackport-0.2.2}/tests/test_readonly_middleware.py +0 -0
  59. {stackport-0.2.1 → stackport-0.2.2}/tests/test_registries.py +0 -0
  60. {stackport-0.2.1 → stackport-0.2.2}/tests/test_routes.py +0 -0
  61. {stackport-0.2.1 → stackport-0.2.2}/tests/test_s3_routes.py +0 -0
  62. {stackport-0.2.1 → stackport-0.2.2}/tests/test_s3_upload_limit_env.py +0 -0
  63. {stackport-0.2.1 → stackport-0.2.2}/tests/test_secretsmanager_routes.py +0 -0
  64. {stackport-0.2.1 → stackport-0.2.2}/tests/test_tags_routes.py +0 -0
  65. {stackport-0.2.1 → stackport-0.2.2}/tests/test_websocket.py +0 -0
  66. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/acm.svg +0 -0
  67. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/apigateway.svg +0 -0
  68. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/appsync.svg +0 -0
  69. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/athena.svg +0 -0
  70. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/cloudformation.svg +0 -0
  71. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/cloudfront.svg +0 -0
  72. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/cognito-idp.svg +0 -0
  73. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/dynamodb.svg +0 -0
  74. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/ec2.svg +0 -0
  75. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/ecr.svg +0 -0
  76. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/ecs.svg +0 -0
  77. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/elasticache.svg +0 -0
  78. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
  79. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
  80. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
  81. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/events.svg +0 -0
  82. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/firehose.svg +0 -0
  83. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/glue.svg +0 -0
  84. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/iam.svg +0 -0
  85. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/kinesis.svg +0 -0
  86. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/kms.svg +0 -0
  87. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/lambda.svg +0 -0
  88. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/logs.svg +0 -0
  89. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/monitoring.svg +0 -0
  90. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/rds.svg +0 -0
  91. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/route53.svg +0 -0
  92. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/s3.svg +0 -0
  93. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/secretsmanager.svg +0 -0
  94. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/ses.svg +0 -0
  95. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/sns.svg +0 -0
  96. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/sqs.svg +0 -0
  97. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/ssm.svg +0 -0
  98. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/stepfunctions.svg +0 -0
  99. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/wafv2.svg +0 -0
  100. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/favicon.png +0 -0
  101. {stackport-0.2.1 → stackport-0.2.2}/ui/dist/favicon.svg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackport
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Universal AWS resource browser for local emulators
5
5
  Author: Davi Reis Vieira
6
6
  License: MIT
@@ -2,11 +2,11 @@ import logging
2
2
  from typing import Any
3
3
 
4
4
  from fastapi import APIRouter, Depends, Query
5
- from pydantic import BaseModel
6
5
 
7
6
  from backend.aws_client import get_client
8
7
  from backend.cache import cache
9
8
  from backend.routes.common import get_endpoint_url
9
+ from backend.schemas.dynamodb import QueryRequest
10
10
 
11
11
  logger = logging.getLogger(__name__)
12
12
 
@@ -154,12 +154,6 @@ def scan_table(
154
154
  return {"error": str(e), "items": [], "count": 0}
155
155
 
156
156
 
157
- class QueryRequest(BaseModel):
158
- partition_key_value: str
159
- sort_key_value: str | None = None
160
- sort_key_operator: str = "=" # =, <, <=, >, >=, BETWEEN, BEGINS_WITH
161
- limit: int = 25
162
-
163
157
 
164
158
  @router.post("/tables/{name}/query")
165
159
  def query_table(name: str, request: QueryRequest, endpoint_url: str | None = Depends(get_endpoint_url)):
@@ -6,12 +6,11 @@ from typing import Annotated
6
6
  from botocore.exceptions import ClientError
7
7
  from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
8
8
  from fastapi.responses import StreamingResponse
9
- from pydantic import BaseModel, model_validator
10
-
11
9
  from backend.aws_client import get_client
12
10
  from backend.cache import cache
13
11
  from backend.config import AWS_REGION, S3_MAX_UPLOAD_BYTES, is_local_endpoint
14
12
  from backend.routes.common import get_endpoint_url
13
+ from backend.schemas.s3 import CreateFolderBody, DeleteBatchBody
15
14
 
16
15
  logger = logging.getLogger(__name__)
17
16
 
@@ -191,32 +190,6 @@ def list_objects(
191
190
  }
192
191
 
193
192
 
194
- class DeleteBatchBody(BaseModel):
195
- """Delete by explicit keys or by prefix (recursive). Provide exactly one."""
196
-
197
- keys: list[str] | None = None
198
- prefix: str | None = None
199
-
200
- @model_validator(mode="after")
201
- def exactly_one_mode(self):
202
- has_keys = bool(self.keys)
203
- has_prefix = bool(self.prefix and self.prefix.strip())
204
- if has_keys == has_prefix:
205
- raise ValueError('Provide exactly one of non-empty "keys" or "prefix"')
206
- return self
207
-
208
-
209
- class CreateFolderBody(BaseModel):
210
- prefix: str
211
-
212
- @model_validator(mode="after")
213
- def trailing_slash(self):
214
- if not self.prefix.endswith("/"):
215
- raise ValueError('Folder prefix must end with "/"')
216
- if ".." in self.prefix or self.prefix.startswith("/"):
217
- raise ValueError("Invalid prefix")
218
- return self
219
-
220
193
 
221
194
  def _validate_object_key(key: str) -> None:
222
195
  if ".." in key or key.startswith("/"):
@@ -0,0 +1,575 @@
1
+ """SQS service-specific routes."""
2
+
3
+ import json
4
+ from typing import Any
5
+ from urllib.parse import unquote
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Query
8
+ from fastapi.responses import Response
9
+
10
+ from backend.aws_client import get_client
11
+ from backend.routes.common import get_endpoint_url
12
+ from backend.schemas.sqs import (
13
+ BatchDeleteRequest,
14
+ BatchSendRequest,
15
+ CreateQueueRequest,
16
+ SendMessageRequest,
17
+ UpdateAttributesRequest,
18
+ UpdateRedrivePolicyRequest,
19
+ )
20
+
21
+ router = APIRouter()
22
+
23
+
24
+ def _extract_queue_name(queue_url: str) -> str:
25
+ """Extract queue name from SQS URL."""
26
+ return queue_url.rsplit("/", 1)[-1]
27
+
28
+
29
+ def _parse_redrive_policy(redrive_policy_json: str | None) -> dict[str, Any] | None:
30
+ """Parse RedrivePolicy JSON string into structured dict."""
31
+ if not redrive_policy_json:
32
+ return None
33
+ try:
34
+ return json.loads(redrive_policy_json)
35
+ except (json.JSONDecodeError, TypeError):
36
+ return None
37
+
38
+
39
+ @router.get("/queues")
40
+ def list_queues(endpoint_url: str | None = Depends(get_endpoint_url)) -> dict[str, Any]:
41
+ """List all SQS queues with enriched attributes.
42
+
43
+ Returns queue name, URL, message counts, type, and key attributes.
44
+ """
45
+ try:
46
+ client = get_client("sqs", endpoint_url)
47
+ response = client.list_queues()
48
+ queue_urls = response.get("QueueUrls", [])
49
+
50
+ queues = []
51
+ for url in queue_urls:
52
+ try:
53
+ # Get all attributes for the queue
54
+ attrs_response = client.get_queue_attributes(
55
+ QueueUrl=url, AttributeNames=["All"]
56
+ )
57
+ attrs = attrs_response.get("Attributes", {})
58
+
59
+ # Get tags
60
+ try:
61
+ tags_response = client.list_queue_tags(QueueUrl=url)
62
+ tags = tags_response.get("Tags", {})
63
+ except Exception:
64
+ tags = {}
65
+
66
+ queue_name = _extract_queue_name(url)
67
+ is_fifo = queue_name.endswith(".fifo") or attrs.get("FifoQueue") == "true"
68
+
69
+ queues.append(
70
+ {
71
+ "name": queue_name,
72
+ "url": url,
73
+ "type": "FIFO" if is_fifo else "Standard",
74
+ "approximateNumberOfMessages": int(
75
+ attrs.get("ApproximateNumberOfMessages", 0)
76
+ ),
77
+ "approximateNumberOfMessagesNotVisible": int(
78
+ attrs.get("ApproximateNumberOfMessagesNotVisible", 0)
79
+ ),
80
+ "approximateNumberOfMessagesDelayed": int(
81
+ attrs.get("ApproximateNumberOfMessagesDelayed", 0)
82
+ ),
83
+ "visibilityTimeout": int(attrs.get("VisibilityTimeout", 30)),
84
+ "messageRetentionPeriod": int(
85
+ attrs.get("MessageRetentionPeriod", 345600)
86
+ ),
87
+ "delaySeconds": int(attrs.get("DelaySeconds", 0)),
88
+ "redrivePolicy": _parse_redrive_policy(
89
+ attrs.get("RedrivePolicy")
90
+ ),
91
+ "tags": tags,
92
+ }
93
+ )
94
+ except Exception:
95
+ # Skip queues that fail to fetch attributes
96
+ continue
97
+
98
+ return {"queues": queues}
99
+ except Exception as e:
100
+ raise HTTPException(status_code=500, detail=str(e))
101
+
102
+
103
+ @router.post("/queues")
104
+ def create_queue(body: CreateQueueRequest, endpoint_url: str | None = Depends(get_endpoint_url)) -> dict[str, Any]:
105
+ """Create a new SQS queue."""
106
+ try:
107
+ client = get_client("sqs", endpoint_url)
108
+
109
+ queue_name = body.queue_name
110
+ is_fifo = body.queue_type == "FIFO"
111
+
112
+ if is_fifo and not queue_name.endswith(".fifo"):
113
+ queue_name = f"{queue_name}.fifo"
114
+
115
+ attributes: dict[str, str] = {}
116
+
117
+ if is_fifo:
118
+ attributes["FifoQueue"] = "true"
119
+
120
+ if body.content_based_deduplication:
121
+ if not is_fifo:
122
+ raise HTTPException(
123
+ status_code=400,
124
+ detail="ContentBasedDeduplication is only valid for FIFO queues",
125
+ )
126
+ attributes["ContentBasedDeduplication"] = "true"
127
+
128
+ if body.visibility_timeout is not None:
129
+ attributes["VisibilityTimeout"] = str(body.visibility_timeout)
130
+ if body.message_retention_period is not None:
131
+ attributes["MessageRetentionPeriod"] = str(body.message_retention_period)
132
+ if body.delay_seconds is not None:
133
+ attributes["DelaySeconds"] = str(body.delay_seconds)
134
+ if body.maximum_message_size is not None:
135
+ attributes["MaximumMessageSize"] = str(body.maximum_message_size)
136
+ if body.receive_message_wait_time is not None:
137
+ attributes["ReceiveMessageWaitTime"] = str(body.receive_message_wait_time)
138
+
139
+ dlq_queue_name = None
140
+
141
+ if body.dlq_enabled and not body.redrive_policy:
142
+ dlq_suffix = "-dlq.fifo" if is_fifo else "-dlq"
143
+ dlq_queue_name = queue_name.removesuffix(".fifo") + dlq_suffix
144
+
145
+ try:
146
+ dlq_url_response = client.get_queue_url(QueueName=dlq_queue_name)
147
+ dlq_url = dlq_url_response["QueueUrl"]
148
+ dlq_attrs_response = client.get_queue_attributes(
149
+ QueueUrl=dlq_url, AttributeNames=["QueueArn"]
150
+ )
151
+ dlq_arn = dlq_attrs_response["Attributes"]["QueueArn"]
152
+ except client.exceptions.QueueDoesNotExist:
153
+ dlq_attributes: dict[str, str] = {}
154
+ if is_fifo:
155
+ dlq_attributes["FifoQueue"] = "true"
156
+ dlq_attributes["SqsManagedSseEnabled"] = "true"
157
+
158
+ dlq_response = client.create_queue(
159
+ QueueName=dlq_queue_name, Attributes=dlq_attributes
160
+ )
161
+ dlq_url = dlq_response["QueueUrl"]
162
+ dlq_attrs_response = client.get_queue_attributes(
163
+ QueueUrl=dlq_url, AttributeNames=["QueueArn"]
164
+ )
165
+ dlq_arn = dlq_attrs_response["Attributes"]["QueueArn"]
166
+
167
+ redrive = {
168
+ "deadLetterTargetArn": dlq_arn,
169
+ "maxReceiveCount": body.max_receive_count,
170
+ }
171
+ attributes["RedrivePolicy"] = json.dumps(redrive)
172
+ elif body.redrive_policy:
173
+ attributes["RedrivePolicy"] = json.dumps(
174
+ body.redrive_policy.model_dump(by_alias=True)
175
+ )
176
+
177
+ if not body.sqs_managed_sse_enabled:
178
+ attributes["SqsManagedSseEnabled"] = "false"
179
+ if body.kms_master_key_id:
180
+ attributes["KmsMasterKeyId"] = body.kms_master_key_id
181
+ else:
182
+ attributes["SqsManagedSseEnabled"] = "true"
183
+
184
+ create_kwargs: dict[str, Any] = {"QueueName": queue_name}
185
+ if attributes:
186
+ create_kwargs["Attributes"] = attributes
187
+
188
+ response = client.create_queue(**create_kwargs)
189
+
190
+ queue_url = response["QueueUrl"]
191
+ arn_response = client.get_queue_attributes(
192
+ QueueUrl=queue_url, AttributeNames=["QueueArn"]
193
+ )
194
+ queue_arn = arn_response["Attributes"]["QueueArn"]
195
+
196
+ if body.tags:
197
+ try:
198
+ client.tag_queue(QueueUrl=queue_url, Tags=body.tags)
199
+ except Exception:
200
+ pass
201
+
202
+ result: dict[str, str] = {
203
+ "queueName": queue_name,
204
+ "queueUrl": queue_url,
205
+ "queueArn": queue_arn,
206
+ }
207
+
208
+ if dlq_queue_name:
209
+ result["dlqQueueName"] = dlq_queue_name
210
+
211
+ return result
212
+ except HTTPException:
213
+ raise
214
+ except Exception as e:
215
+ raise HTTPException(status_code=500, detail=str(e))
216
+
217
+
218
+ @router.get("/queues/{queue_name}")
219
+ def get_queue_detail(queue_name: str, endpoint_url: str | None = Depends(get_endpoint_url)) -> dict[str, Any]:
220
+ """Get detailed attributes and tags for a specific queue."""
221
+ try:
222
+ client = get_client("sqs", endpoint_url)
223
+
224
+ # Get queue URL from name
225
+ url_response = client.get_queue_url(QueueName=queue_name)
226
+ queue_url = url_response["QueueUrl"]
227
+
228
+ # Get all attributes
229
+ attrs_response = client.get_queue_attributes(
230
+ QueueUrl=queue_url, AttributeNames=["All"]
231
+ )
232
+ attrs = attrs_response.get("Attributes", {})
233
+
234
+ # Get tags
235
+ try:
236
+ tags_response = client.list_queue_tags(QueueUrl=queue_url)
237
+ tags = tags_response.get("Tags", {})
238
+ except Exception:
239
+ tags = {}
240
+
241
+ is_fifo = queue_name.endswith(".fifo") or attrs.get("FifoQueue") == "true"
242
+
243
+ return {
244
+ "name": queue_name,
245
+ "url": queue_url,
246
+ "arn": attrs.get("QueueArn"),
247
+ "type": "FIFO" if is_fifo else "Standard",
248
+ "approximateNumberOfMessages": int(
249
+ attrs.get("ApproximateNumberOfMessages", 0)
250
+ ),
251
+ "approximateNumberOfMessagesNotVisible": int(
252
+ attrs.get("ApproximateNumberOfMessagesNotVisible", 0)
253
+ ),
254
+ "approximateNumberOfMessagesDelayed": int(
255
+ attrs.get("ApproximateNumberOfMessagesDelayed", 0)
256
+ ),
257
+ "visibilityTimeout": int(attrs.get("VisibilityTimeout", 30)),
258
+ "messageRetentionPeriod": int(attrs.get("MessageRetentionPeriod", 345600)),
259
+ "maximumMessageSize": int(attrs.get("MaximumMessageSize", 262144)),
260
+ "delaySeconds": int(attrs.get("DelaySeconds", 0)),
261
+ "redrivePolicy": _parse_redrive_policy(attrs.get("RedrivePolicy")),
262
+ "contentBasedDeduplication": attrs.get("ContentBasedDeduplication") == "true",
263
+ "tags": tags,
264
+ }
265
+ except client.exceptions.QueueDoesNotExist:
266
+ raise HTTPException(status_code=404, detail=f"Queue {queue_name} not found")
267
+ except Exception as e:
268
+ raise HTTPException(status_code=500, detail=str(e))
269
+
270
+
271
+ @router.post("/queues/{queue_name}/messages")
272
+ def send_message(queue_name: str, body: SendMessageRequest, endpoint_url: str | None = Depends(get_endpoint_url)) -> dict[str, Any]:
273
+ """Send a message to the queue."""
274
+ try:
275
+ client = get_client("sqs", endpoint_url)
276
+
277
+ url_response = client.get_queue_url(QueueName=queue_name)
278
+ queue_url = url_response["QueueUrl"]
279
+
280
+ send_kwargs: dict[str, Any] = {
281
+ "QueueUrl": queue_url,
282
+ "MessageBody": body.message_body,
283
+ }
284
+
285
+ if body.delay_seconds is not None:
286
+ send_kwargs["DelaySeconds"] = body.delay_seconds
287
+
288
+ if body.message_attributes:
289
+ attrs = {}
290
+ for key, value in body.message_attributes.items():
291
+ attrs[key] = {
292
+ "StringValue": str(value.get("stringValue", "")),
293
+ "DataType": value.get("dataType", "String"),
294
+ }
295
+ send_kwargs["MessageAttributes"] = attrs
296
+
297
+ if body.message_deduplication_id:
298
+ send_kwargs["MessageDeduplicationId"] = body.message_deduplication_id
299
+ if body.message_group_id:
300
+ send_kwargs["MessageGroupId"] = body.message_group_id
301
+
302
+ response = client.send_message(**send_kwargs)
303
+
304
+ return {
305
+ "messageId": response["MessageId"],
306
+ "md5OfMessageBody": response["MD5OfMessageBody"],
307
+ "sequenceNumber": response.get("SequenceNumber"),
308
+ }
309
+ except client.exceptions.QueueDoesNotExist:
310
+ raise HTTPException(status_code=404, detail=f"Queue {queue_name} not found")
311
+ except Exception as e:
312
+ raise HTTPException(status_code=500, detail=str(e))
313
+
314
+
315
+ @router.get("/queues/{queue_name}/messages")
316
+ def receive_messages(
317
+ queue_name: str,
318
+ max_messages: int = Query(10, ge=1, le=10),
319
+ visibility_timeout: int = Query(0, ge=0, le=43200),
320
+ endpoint_url: str | None = Depends(get_endpoint_url),
321
+ ) -> dict[str, Any]:
322
+ """Receive messages from the queue.
323
+
324
+ Use visibility_timeout=0 to peek without consuming messages.
325
+ Use visibility_timeout > 0 to prevent redelivery during inspection.
326
+ """
327
+ try:
328
+ client = get_client("sqs", endpoint_url)
329
+
330
+ # Get queue URL from name
331
+ url_response = client.get_queue_url(QueueName=queue_name)
332
+ queue_url = url_response["QueueUrl"]
333
+
334
+ response = client.receive_message(
335
+ QueueUrl=queue_url,
336
+ MaxNumberOfMessages=max_messages,
337
+ VisibilityTimeout=visibility_timeout,
338
+ MessageAttributeNames=["All"],
339
+ AttributeNames=["All"],
340
+ )
341
+
342
+ messages = response.get("Messages", [])
343
+
344
+ # Structure the messages for the frontend
345
+ formatted_messages = []
346
+ for msg in messages:
347
+ formatted_messages.append(
348
+ {
349
+ "messageId": msg.get("MessageId"),
350
+ "receiptHandle": msg.get("ReceiptHandle"),
351
+ "body": msg.get("Body"),
352
+ "md5OfBody": msg.get("MD5OfBody"),
353
+ "attributes": msg.get("Attributes", {}),
354
+ "messageAttributes": msg.get("MessageAttributes", {}),
355
+ }
356
+ )
357
+
358
+ return {"messages": formatted_messages}
359
+ except client.exceptions.QueueDoesNotExist:
360
+ raise HTTPException(status_code=404, detail=f"Queue {queue_name} not found")
361
+ except Exception as e:
362
+ raise HTTPException(status_code=500, detail=str(e))
363
+
364
+
365
+ @router.delete("/queues/{queue_name}/messages")
366
+ def delete_message(queue_name: str, receipt_handle: str = Query(...), endpoint_url: str | None = Depends(get_endpoint_url)) -> Response:
367
+ """Delete a message from the queue using its receipt handle."""
368
+ try:
369
+ client = get_client("sqs", endpoint_url)
370
+
371
+ # Get queue URL from name
372
+ url_response = client.get_queue_url(QueueName=queue_name)
373
+ queue_url = url_response["QueueUrl"]
374
+
375
+ # Decode receipt handle (it may be URL-encoded)
376
+ decoded_handle = unquote(receipt_handle)
377
+
378
+ client.delete_message(QueueUrl=queue_url, ReceiptHandle=decoded_handle)
379
+
380
+ return Response(status_code=204)
381
+ except client.exceptions.QueueDoesNotExist:
382
+ raise HTTPException(status_code=404, detail=f"Queue {queue_name} not found")
383
+ except client.exceptions.ReceiptHandleIsInvalid:
384
+ raise HTTPException(status_code=400, detail="Receipt handle is invalid or expired")
385
+ except Exception as e:
386
+ raise HTTPException(status_code=500, detail=str(e))
387
+
388
+
389
+ @router.post("/queues/{queue_name}/purge")
390
+ def purge_queue(queue_name: str, endpoint_url: str | None = Depends(get_endpoint_url)) -> dict[str, Any]:
391
+ """Purge all messages from the queue.
392
+
393
+ Note: Can only be called once every 60 seconds.
394
+ """
395
+ try:
396
+ client = get_client("sqs", endpoint_url)
397
+
398
+ # Get queue URL from name
399
+ url_response = client.get_queue_url(QueueName=queue_name)
400
+ queue_url = url_response["QueueUrl"]
401
+
402
+ client.purge_queue(QueueUrl=queue_url)
403
+
404
+ return {"success": True, "message": f"Queue {queue_name} purge initiated"}
405
+ except client.exceptions.QueueDoesNotExist:
406
+ raise HTTPException(status_code=404, detail=f"Queue {queue_name} not found")
407
+ except client.exceptions.PurgeQueueInProgress:
408
+ raise HTTPException(
409
+ status_code=409,
410
+ detail="Purge already in progress. Wait 60 seconds before purging again.",
411
+ )
412
+ except Exception as e:
413
+ raise HTTPException(status_code=500, detail=str(e))
414
+
415
+
416
+ @router.delete("/queues/{queue_name}")
417
+ def delete_queue(queue_name: str, endpoint_url: str | None = Depends(get_endpoint_url)) -> Response:
418
+ """Delete an SQS queue.
419
+
420
+ Permanently deletes the queue and all its messages.
421
+ """
422
+ try:
423
+ client = get_client("sqs", endpoint_url)
424
+
425
+ # Get queue URL from name
426
+ url_response = client.get_queue_url(QueueName=queue_name)
427
+ queue_url = url_response["QueueUrl"]
428
+
429
+ client.delete_queue(QueueUrl=queue_url)
430
+
431
+ return Response(status_code=204)
432
+ except client.exceptions.QueueDoesNotExist:
433
+ raise HTTPException(status_code=404, detail=f"Queue {queue_name} not found")
434
+ except Exception as e:
435
+ raise HTTPException(status_code=500, detail=str(e))
436
+
437
+
438
+ @router.put("/queues/{queue_name}/attributes")
439
+ def update_queue_attributes(queue_name: str, body: UpdateAttributesRequest, endpoint_url: str | None = Depends(get_endpoint_url)) -> dict[str, Any]:
440
+ """Update queue attributes."""
441
+ try:
442
+ client = get_client("sqs", endpoint_url)
443
+
444
+ url_response = client.get_queue_url(QueueName=queue_name)
445
+ queue_url = url_response["QueueUrl"]
446
+
447
+ attributes: dict[str, str] = {}
448
+
449
+ if body.visibility_timeout is not None:
450
+ attributes["VisibilityTimeout"] = str(body.visibility_timeout)
451
+ if body.message_retention_period is not None:
452
+ attributes["MessageRetentionPeriod"] = str(body.message_retention_period)
453
+ if body.delay_seconds is not None:
454
+ attributes["DelaySeconds"] = str(body.delay_seconds)
455
+ if body.maximum_message_size is not None:
456
+ attributes["MaximumMessageSize"] = str(body.maximum_message_size)
457
+ if body.receive_message_wait_time is not None:
458
+ attributes["ReceiveMessageWaitTime"] = str(body.receive_message_wait_time)
459
+
460
+ if not attributes:
461
+ raise HTTPException(status_code=400, detail="No attributes provided")
462
+
463
+ client.set_queue_attributes(QueueUrl=queue_url, Attributes=attributes)
464
+
465
+ return {
466
+ "success": True,
467
+ "message": f"Queue {queue_name} attributes updated successfully",
468
+ }
469
+ except client.exceptions.QueueDoesNotExist:
470
+ raise HTTPException(status_code=404, detail=f"Queue {queue_name} not found")
471
+ except HTTPException:
472
+ raise
473
+ except Exception as e:
474
+ raise HTTPException(status_code=500, detail=str(e))
475
+
476
+
477
+ @router.post("/queues/{queue_name}/messages/batch")
478
+ def send_messages_batch(queue_name: str, body: BatchSendRequest, endpoint_url: str | None = Depends(get_endpoint_url)) -> dict[str, Any]:
479
+ """Send multiple messages to the queue in one operation (max 10)."""
480
+ try:
481
+ client = get_client("sqs", endpoint_url)
482
+
483
+ url_response = client.get_queue_url(QueueName=queue_name)
484
+ queue_url = url_response["QueueUrl"]
485
+
486
+ batch_entries = []
487
+ for entry in body.entries:
488
+ batch_entry: dict[str, Any] = {
489
+ "Id": entry.id,
490
+ "MessageBody": entry.message_body,
491
+ }
492
+
493
+ if entry.delay_seconds is not None:
494
+ batch_entry["DelaySeconds"] = entry.delay_seconds
495
+ if entry.message_deduplication_id:
496
+ batch_entry["MessageDeduplicationId"] = entry.message_deduplication_id
497
+ if entry.message_group_id:
498
+ batch_entry["MessageGroupId"] = entry.message_group_id
499
+
500
+ batch_entries.append(batch_entry)
501
+
502
+ response = client.send_message_batch(
503
+ QueueUrl=queue_url, Entries=batch_entries
504
+ )
505
+
506
+ successful = [
507
+ {"id": entry["Id"], "messageId": entry["MessageId"]}
508
+ for entry in response.get("Successful", [])
509
+ ]
510
+ failed = [
511
+ {
512
+ "id": entry["Id"],
513
+ "code": entry.get("Code", ""),
514
+ "message": entry.get("Message", ""),
515
+ }
516
+ for entry in response.get("Failed", [])
517
+ ]
518
+
519
+ return {"successful": successful, "failed": failed}
520
+ except client.exceptions.QueueDoesNotExist:
521
+ raise HTTPException(status_code=404, detail=f"Queue {queue_name} not found")
522
+ except Exception as e:
523
+ raise HTTPException(status_code=500, detail=str(e))
524
+
525
+
526
+ @router.delete("/queues/{queue_name}/messages/batch")
527
+ def delete_messages_batch(queue_name: str, body: BatchDeleteRequest, endpoint_url: str | None = Depends(get_endpoint_url)) -> Response:
528
+ """Delete multiple messages from the queue in one operation (max 10)."""
529
+ try:
530
+ client = get_client("sqs", endpoint_url)
531
+
532
+ url_response = client.get_queue_url(QueueName=queue_name)
533
+ queue_url = url_response["QueueUrl"]
534
+
535
+ batch_entries = []
536
+ for idx, receipt_handle in enumerate(body.receipt_handles):
537
+ decoded_handle = unquote(receipt_handle)
538
+ batch_entries.append(
539
+ {"Id": str(idx), "ReceiptHandle": decoded_handle}
540
+ )
541
+
542
+ client.delete_message_batch(QueueUrl=queue_url, Entries=batch_entries)
543
+
544
+ return Response(status_code=204)
545
+ except client.exceptions.QueueDoesNotExist:
546
+ raise HTTPException(status_code=404, detail=f"Queue {queue_name} not found")
547
+ except Exception as e:
548
+ raise HTTPException(status_code=500, detail=str(e))
549
+
550
+
551
+ @router.put("/queues/{queue_name}/redrive-policy")
552
+ def update_redrive_policy(queue_name: str, body: UpdateRedrivePolicyRequest, endpoint_url: str | None = Depends(get_endpoint_url)) -> dict[str, Any]:
553
+ """Update the dead-letter queue redrive policy."""
554
+ try:
555
+ client = get_client("sqs", endpoint_url)
556
+
557
+ url_response = client.get_queue_url(QueueName=queue_name)
558
+ queue_url = url_response["QueueUrl"]
559
+
560
+ redrive_policy = {
561
+ "deadLetterTargetArn": body.dead_letter_target_arn,
562
+ "maxReceiveCount": body.max_receive_count,
563
+ }
564
+ attributes = {"RedrivePolicy": json.dumps(redrive_policy)}
565
+
566
+ client.set_queue_attributes(QueueUrl=queue_url, Attributes=attributes)
567
+
568
+ return {
569
+ "success": True,
570
+ "message": f"Queue {queue_name} redrive policy updated successfully",
571
+ }
572
+ except client.exceptions.QueueDoesNotExist:
573
+ raise HTTPException(status_code=404, detail=f"Queue {queue_name} not found")
574
+ except Exception as e:
575
+ raise HTTPException(status_code=500, detail=str(e))
@@ -3,28 +3,14 @@
3
3
  from typing import Any
4
4
 
5
5
  from fastapi import APIRouter, Depends, HTTPException
6
- from pydantic import BaseModel
7
6
 
8
7
  from backend.aws_client import get_client
9
8
  from backend.routes.common import get_endpoint_url
9
+ from backend.schemas.tags import BulkDeleteRequest, BulkTagRequest, TagUpdateRequest
10
10
 
11
11
  router = APIRouter()
12
12
 
13
13
 
14
- class TagUpdateRequest(BaseModel):
15
- tags: dict[str, str]
16
-
17
-
18
- class BulkTagRequest(BaseModel):
19
- action: str # "add" or "remove"
20
- tags: dict[str, str]
21
- resources: list[dict[str, str]] # [{"service": "s3", "type": "buckets", "id": "my-bucket"}, ...]
22
-
23
-
24
- class BulkDeleteRequest(BaseModel):
25
- resources: list[dict[str, str]] # [{"service": "s3", "type": "buckets", "id": "my-bucket"}, ...]
26
-
27
-
28
14
  # --- Tag getters: (service, type) -> callable(client, resource_id) -> dict ---
29
15
 
30
16
  def _get_tags_s3_bucket(client: Any, resource_id: str) -> dict[str, str]: