1use crate::llm::actions::{
6 protocol_trait::{ActionResult, Protocol, Server},
7 ActionDefinition, Parameter,
8};
9use crate::protocol::log_template::LogTemplate;
10use crate::protocol::EventType;
11use crate::state::app_state::AppState;
12use anyhow::Result;
13use serde_json::{json, Value};
14use std::sync::LazyLock;
15
16pub struct DynamoProtocol {
18 }
20
21impl DynamoProtocol {
22 pub fn new() -> Self {
23 Self {}
24 }
25}
26
27pub static DYNAMO_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
29 EventType::new(
30 "dynamo_request",
31 "DynamoDB API request received",
32 json!({"type": "placeholder", "event_id": "dynamo_request"}),
33 )
34 .with_parameters(vec![
35 Parameter {
36 name: "operation".to_string(),
37 type_hint: "string".to_string(),
38 description: "DynamoDB operation (GetItem, PutItem, Query, etc.)".to_string(),
39 required: true,
40 },
41 Parameter {
42 name: "table_name".to_string(),
43 type_hint: "string".to_string(),
44 description: "Target table name (if available)".to_string(),
45 required: false,
46 },
47 Parameter {
48 name: "request_body".to_string(),
49 type_hint: "string".to_string(),
50 description: "JSON request body".to_string(),
51 required: true,
52 },
53 ])
54 .with_actions(vec![send_dynamo_response_action()])
55 .with_log_template(
56 LogTemplate::new()
57 .with_info("DynamoDB {operation}")
58 .with_debug("DynamoDB {operation} on {table_name}")
59 .with_trace("DynamoDB: {json_pretty(.)}"),
60 )
61});
62
63fn send_dynamo_response_action() -> ActionDefinition {
64 ActionDefinition {
65 name: "send_dynamo_response".to_string(),
66 description: "Send DynamoDB JSON response with HTTP status code".to_string(),
67 parameters: vec![
68 Parameter {
69 name: "status_code".to_string(),
70 type_hint: "number".to_string(),
71 description: "HTTP status code (200, 400, 500, etc.)".to_string(),
72 required: true,
73 },
74 Parameter {
75 name: "body".to_string(),
76 type_hint: "string".to_string(),
77 description: "JSON response body".to_string(),
78 required: true,
79 },
80 ],
81 example: serde_json::json!({
82 "type": "send_dynamo_response",
83 "status_code": 200,
84 "body": "{\"Item\": {\"id\": {\"S\": \"user-123\"}, \"name\": {\"S\": \"Alice\"}}}"
85 }),
86 log_template: Some(
87 LogTemplate::new()
88 .with_info("-> DynamoDB response (HTTP {status_code})")
89 .with_debug("DynamoDB send_dynamo_response: status={status_code}"),
90 ),
91 }
92}
93
94fn parse_status_code(action: &Value) -> Result<u16> {
100 let raw = action
101 .get("status_code")
102 .and_then(|v| v.as_u64())
103 .ok_or_else(|| anyhow::anyhow!("Missing or invalid status_code: expected a number"))?;
104
105 if !(100..=599).contains(&raw) {
106 return Err(anyhow::anyhow!(
107 "Invalid status_code {raw}: must be an HTTP status between 100 and 599. \
108 DynamoDB uses 200 for success and 400 with an \
109 {{\"__type\": ..., \"message\": ...}} body for client errors."
110 ));
111 }
112
113 Ok(raw as u16)
114}
115
116pub fn get_dynamo_event_types() -> Vec<EventType> {
117 vec![DYNAMO_REQUEST_EVENT.clone()]
118}
119
120impl Protocol for DynamoProtocol {
122 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
123 vec![]
125 }
126 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
127 vec![send_dynamo_response_action()]
128 }
129 fn protocol_name(&self) -> &'static str {
130 "DynamoDB"
131 }
132 fn get_event_types(&self) -> Vec<EventType> {
133 get_dynamo_event_types()
134 }
135 fn stack_name(&self) -> &'static str {
136 "ETH>IP>TCP>HTTP>DYNAMODB"
137 }
138 fn keywords(&self) -> Vec<&'static str> {
139 vec!["dynamo", "dynamodb", "dynamo db", "aws dynamodb"]
143 }
144 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
145 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
146
147 ProtocolMetadataV2::builder()
148 .state(DevelopmentState::Beta)
149 .well_known_port(8000)
150 .implementation("hyper v1.5 HTTP with manual DynamoDB API")
151 .llm_control("All DynamoDB operations (GetItem, PutItem, Query)")
152 .e2e_testing(
153 "TWO independent clients, neither #[ignore]d and neither able to skip. \
154 (1) aws-sdk-dynamodb, the official Rust SDK, in \
155 tests/server/dynamo/e2e_aws_sdk_test.rs: CreateTable, PutItem/GetItem and \
156 UpdateItem complete through the SDK. \
157 (2) the real `aws` CLI (botocore, Python) in \
158 tests/server/dynamo/real_client_test.rs: ListTables, PutItem and GetItem, \
159 asserting the TYPED attribute values it rendered -- a string under S, a number \
160 under N, a boolean under BOOL. That is the check worth having, because \
161 DynamoDB numbers cross the wire as STRINGS under an N tag and botocore rejects \
162 an item whose tags it does not recognise rather than rendering it as text. \
163 Verified by sending `\"count\":{\"N\":7}` as a JSON number, which the test \
164 then reports. \
165 THE TEST CANNOT REACH REAL AWS, and for this protocol that is not abstract: \
166 the project CLAUDE.md records that this protocol's CLIENT once dropped its \
167 target, let the SDK resolve the production endpoint and signed with ambient \
168 credentials. --endpoint-url is passed on every invocation, the port is asserted \
169 non-zero before the CLI is spawned, and credentials, region, profile and the \
170 EC2 metadata service are overridden in the child's environment. \
171 UNPROVEN, and worth saying plainly: NO SIGNATURE IS VALIDATED -- every request \
172 is served unconditionally and neither Authorization nor X-Amz-Date reaches the \
173 event, so the model cannot make that decision either. Also unproven: Query and \
174 Scan, conditional writes, transactions, streams, and pagination.",
175 )
176 .notes("Virtual data (no persistence)")
177 .max_inbound_bytes(crate::server::dynamo::MAX_REQUEST_BYTES)
178 .request_only("DynamoDB is HTTP request/response; a server cannot send a peer anything unprompted")
179 .build()
180 }
181 fn description(&self) -> &'static str {
182 "DynamoDB-compatible database server"
183 }
184 fn example_prompt(&self) -> &'static str {
185 "Start a DynamoDB-compatible server on port 8000"
186 }
187 fn group_name(&self) -> &'static str {
188 "Database"
189 }
190
191 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
192 use crate::llm::actions::StartupExamples;
193 use serde_json::json;
194
195 let script = r#"import json, sys
198data = json.load(sys.stdin)
199event = data["event"]
200if data["event_type_id"] == "dynamo_request":
201 op = event.get("operation", "")
202 if op == "GetItem":
203 body = json.dumps({"Item": {"id": {"S": "item-1"},
204 "name": {"S": "Example"}}})
205 else:
206 body = json.dumps({})
207 actions = [{"type": "send_dynamo_response", "status_code": 200, "body": body}]
208else:
209 actions = []
210print(json.dumps({"actions": actions}))"#;
211
212 StartupExamples::new(
213 json!({
215 "type": "open_server",
216 "port": 8000,
217 "base_stack": "dynamo",
218 "instruction": "Emulate a DynamoDB table 'Users' keyed by userId. On PutItem remember the item; on GetItem return the stored item for that key (or an empty response if absent); on Query return every remembered item whose userId matches. Format all responses in DynamoDB JSON with typed attribute values."
219 }),
220 json!({
222 "type": "open_server",
223 "port": 8000,
224 "base_stack": "dynamo",
225 "event_handlers": [{
226 "event_pattern": "dynamo_request",
227 "handler": {
228 "type": "script",
229 "language": "python",
230 "code": script
231 }
232 }]
233 }),
234 json!({
236 "type": "open_server",
237 "port": 8000,
238 "base_stack": "dynamo",
239 "event_handlers": [{
240 "event_pattern": "dynamo_request",
241 "handler": {
242 "type": "static",
243 "actions": [{
244 "type": "send_dynamo_response",
245 "status_code": 200,
246 "body": "{}"
247 }]
248 }
249 }]
250 }),
251 )
252 }
253}
254
255impl Server for DynamoProtocol {
257 fn spawn(
258 &self,
259 ctx: crate::protocol::SpawnContext,
260 ) -> std::pin::Pin<
261 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
262 > {
263 Box::pin(async move {
264 use crate::server::dynamo::DynamoServer;
265 DynamoServer::spawn_with_llm_actions(
266 ctx.legacy_listen_addr(),
267 ctx.llm_client,
268 ctx.state,
269 ctx.status_tx,
270 false,
271 ctx.server_id,
272 )
273 .await
274 })
275 }
276 fn execute_action(&self, action: Value) -> Result<ActionResult> {
277 let action_type = action
278 .get("type")
279 .and_then(|v| v.as_str())
280 .ok_or_else(|| anyhow::anyhow!("Missing action type"))?;
281
282 match action_type {
283 "send_dynamo_response" => {
284 let status_code = parse_status_code(&action)?;
285
286 let body = action
287 .get("body")
288 .and_then(|v| v.as_str())
289 .ok_or_else(|| anyhow::anyhow!("Missing body"))?
290 .to_string();
291
292 Ok(ActionResult::Custom {
293 name: "dynamo_response".to_string(),
294 data: json!({
295 "status": status_code,
296 "body": body
297 }),
298 })
299 }
300 _ => Err(anyhow::anyhow!("Unknown action type: {}", action_type)),
301 }
302 }
303}