1use std::sync::Arc;
2
3use axum::{
4 extract::{Path, State},
5 http::StatusCode,
6 response::{IntoResponse, Response},
7 routing::{get, post, put},
8 Json, Router,
9};
10use tracing::error;
11
12use alien_error::AlienError;
13
14use crate::{
15 error::{Error, ErrorData},
16 server::CommandServer,
17 types::*,
18};
19
20pub trait HasCommandServer {
22 fn command_server(&self) -> &Arc<CommandServer>;
23}
24
25impl HasCommandServer for Arc<CommandServer> {
26 fn command_server(&self) -> &Arc<CommandServer> {
27 self
28 }
29}
30
31pub fn create_axum_router<S>() -> Router<S>
33where
34 S: HasCommandServer + Clone + Send + Sync + 'static,
35{
36 Router::new()
37 .route("/commands", post(create_command::<S>))
38 .route(
39 "/commands/{command_id}/upload-complete",
40 post(upload_complete::<S>),
41 )
42 .route("/commands/{command_id}/response", put(submit_response::<S>))
43 .route("/commands/{command_id}", get(get_command_status::<S>))
44 .route(
45 "/commands/{command_id}/payload",
46 get(get_command_payload::<S>).put(store_command_payload::<S>),
47 )
48 .route("/commands/leases", post(acquire_leases::<S>))
49 .route(
50 "/commands/leases/{lease_id}/release",
51 post(release_lease::<S>),
52 )
53}
54
55#[cfg_attr(feature = "openapi", utoipa::path(
57 post,
58 path = "/commands",
59 request_body = CreateCommandRequest,
60 responses(
61 (status = 200, description = "Command created successfully", body = CreateCommandResponse),
62 (status = 400, description = "Invalid command", body = ErrorResponse),
63 (status = 500, description = "Internal server error", body = ErrorResponse),
64 ),
65 operation_id = "create_command",
66 tag = "commands"
67))]
68async fn create_command<S>(
69 State(state): State<S>,
70 Json(request): Json<CreateCommandRequest>,
71) -> Result<Json<CreateCommandResponse>, ErrorResponse>
72where
73 S: HasCommandServer,
74{
75 let response = state.command_server().create_command(request).await?;
76 Ok(Json(response))
77}
78
79#[cfg_attr(feature = "openapi", utoipa::path(
81 post,
82 path = "/commands/{command_id}/upload-complete",
83 params(
84 ("command_id" = String, Path, description = "Command identifier")
85 ),
86 request_body = UploadCompleteRequest,
87 responses(
88 (status = 200, description = "Upload marked complete", body = UploadCompleteResponse),
89 (status = 400, description = "Invalid command or state", body = ErrorResponse),
90 (status = 404, description = "Command not found", body = ErrorResponse),
91 (status = 500, description = "Internal server error", body = ErrorResponse),
92 ),
93 operation_id = "upload_complete",
94 tag = "commands"
95))]
96async fn upload_complete<S>(
97 State(state): State<S>,
98 Path(command_id): Path<String>,
99 Json(upload_request): Json<UploadCompleteRequest>,
100) -> Result<Json<UploadCompleteResponse>, ErrorResponse>
101where
102 S: HasCommandServer,
103{
104 let response = state
105 .command_server()
106 .upload_complete(&command_id, upload_request)
107 .await?;
108 Ok(Json(response))
109}
110
111#[cfg_attr(feature = "openapi", utoipa::path(
113 get,
114 path = "/commands/{command_id}",
115 params(
116 ("command_id" = String, Path, description = "Command identifier")
117 ),
118 responses(
119 (status = 200, description = "Command status", body = CommandStatusResponse),
120 (status = 404, description = "Command not found", body = ErrorResponse),
121 (status = 500, description = "Internal server error", body = ErrorResponse),
122 ),
123 operation_id = "get_command_status",
124 tag = "commands"
125))]
126async fn get_command_status<S>(
127 State(state): State<S>,
128 Path(command_id): Path<String>,
129) -> Result<Json<CommandStatusResponse>, ErrorResponse>
130where
131 S: HasCommandServer,
132{
133 let response = state
134 .command_server()
135 .get_command_status(&command_id)
136 .await?;
137 Ok(Json(response))
138}
139
140#[derive(Debug, serde::Serialize, serde::Deserialize)]
142#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
143#[serde(rename_all = "camelCase")]
144pub struct CommandPayloadResponse {
145 pub command_id: String,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub params: Option<BodySpec>,
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub response: Option<CommandResponse>,
150}
151
152#[cfg_attr(feature = "openapi", utoipa::path(
157 get,
158 path = "/commands/{command_id}/payload",
159 params(
160 ("command_id" = String, Path, description = "Command identifier")
161 ),
162 responses(
163 (status = 200, description = "Command payload data", body = CommandPayloadResponse),
164 (status = 404, description = "Command payload not found", body = ErrorResponse),
165 (status = 500, description = "Internal server error", body = ErrorResponse),
166 ),
167 operation_id = "get_command_payload",
168 tag = "commands"
169))]
170async fn get_command_payload<S>(
171 State(state): State<S>,
172 Path(command_id): Path<String>,
173) -> Result<Json<CommandPayloadResponse>, ErrorResponse>
174where
175 S: HasCommandServer,
176{
177 let params = state.command_server().get_params(&command_id).await?;
178 let response = state.command_server().get_response(&command_id).await?;
179
180 if params.is_none() && response.is_none() {
182 return Err(AlienError::new(ErrorData::CommandNotFound {
183 command_id: command_id.clone(),
184 })
185 .into());
186 }
187
188 Ok(Json(CommandPayloadResponse {
189 command_id,
190 params,
191 response,
192 }))
193}
194
195#[derive(Debug, serde::Serialize, serde::Deserialize)]
201#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
202#[serde(rename_all = "camelCase")]
203pub struct StorePayloadRequest {
204 #[serde(skip_serializing_if = "Option::is_none")]
205 pub params: Option<BodySpec>,
206 #[serde(skip_serializing_if = "Option::is_none")]
207 pub response: Option<CommandResponse>,
208}
209
210#[cfg_attr(feature = "openapi", utoipa::path(
215 put,
216 path = "/commands/{command_id}/payload",
217 params(
218 ("command_id" = String, Path, description = "Command identifier")
219 ),
220 request_body = StorePayloadRequest,
221 responses(
222 (status = 200, description = "Payload stored successfully"),
223 (status = 400, description = "Invalid request", body = ErrorResponse),
224 (status = 500, description = "Internal server error", body = ErrorResponse),
225 ),
226 operation_id = "store_command_payload",
227 tag = "commands"
228))]
229async fn store_command_payload<S>(
230 State(state): State<S>,
231 Path(command_id): Path<String>,
232 Json(request): Json<StorePayloadRequest>,
233) -> Result<StatusCode, ErrorResponse>
234where
235 S: HasCommandServer,
236{
237 if let Some(params) = &request.params {
238 state
239 .command_server()
240 .store_params(&command_id, params)
241 .await?;
242 }
243
244 if let Some(response) = &request.response {
245 state
246 .command_server()
247 .store_response(&command_id, response)
248 .await?;
249 }
250
251 Ok(StatusCode::OK)
252}
253
254#[cfg_attr(feature = "openapi", utoipa::path(
256 put,
257 path = "/commands/{command_id}/response",
258 params(
259 ("command_id" = String, Path, description = "Command identifier")
260 ),
261 request_body = SubmitResponseRequest,
262 responses(
263 (status = 200, description = "Response submitted successfully"),
264 (status = 400, description = "Invalid command or state", body = ErrorResponse),
265 (status = 404, description = "Command not found", body = ErrorResponse),
266 (status = 500, description = "Internal server error", body = ErrorResponse),
267 ),
268 operation_id = "submit_response",
269 tag = "commands"
270))]
271async fn submit_response<S>(
272 State(state): State<S>,
273 Path(command_id): Path<String>,
274 Json(request): Json<SubmitResponseRequest>,
275) -> Result<StatusCode, ErrorResponse>
276where
277 S: HasCommandServer,
278{
279 state
280 .command_server()
281 .submit_command_response(&command_id, request.response)
282 .await?;
283 Ok(StatusCode::OK)
284}
285
286#[cfg_attr(feature = "openapi", utoipa::path(
288 post,
289 path = "/commands/leases",
290 request_body = LeaseRequest,
291 responses(
292 (status = 200, description = "Leases acquired", body = LeaseResponse),
293 (status = 500, description = "Internal server error", body = ErrorResponse),
294 ),
295 operation_id = "acquire_leases",
296 tag = "leases"
297))]
298async fn acquire_leases<S>(
299 State(state): State<S>,
300 Json(lease_request): Json<LeaseRequest>,
301) -> Result<Json<LeaseResponse>, ErrorResponse>
302where
303 S: HasCommandServer,
304{
305 let response = state
306 .command_server()
307 .acquire_lease(&lease_request.deployment_id, &lease_request)
308 .await?;
309 Ok(Json(response))
310}
311
312#[cfg_attr(feature = "openapi", utoipa::path(
314 post,
315 path = "/commands/leases/{lease_id}/release",
316 params(
317 ("lease_id" = String, Path, description = "Lease identifier")
318 ),
319 responses(
320 (status = 200, description = "Lease released successfully"),
321 (status = 404, description = "Lease not found", body = ErrorResponse),
322 (status = 500, description = "Internal server error", body = ErrorResponse),
323 ),
324 operation_id = "release_lease",
325 tag = "leases"
326))]
327async fn release_lease<S>(
328 State(state): State<S>,
329 Path(lease_id): Path<String>,
330) -> Result<StatusCode, ErrorResponse>
331where
332 S: HasCommandServer,
333{
334 state
335 .command_server()
336 .release_lease_by_id(&lease_id)
337 .await?;
338 Ok(StatusCode::OK)
339}
340
341#[derive(Debug, serde::Serialize)]
345#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
346struct ErrorResponse {
347 pub code: String,
348 pub message: String,
349 #[serde(skip_serializing_if = "Option::is_none")]
350 pub details: Option<String>,
351}
352
353impl From<Error> for ErrorResponse {
354 fn from(error: Error) -> Self {
355 ErrorResponse {
356 code: error.code.clone(),
357 message: error.message.clone(),
358 details: None,
359 }
360 }
361}
362
363impl IntoResponse for ErrorResponse {
364 fn into_response(self) -> Response {
365 let status = match self.code.as_str() {
366 "INVALID_COMMAND" | "INVALID_STATE_TRANSITION" | "INVALID_ENVELOPE" => {
367 StatusCode::BAD_REQUEST
368 }
369 "COMMAND_NOT_FOUND" | "LEASE_NOT_FOUND" => StatusCode::NOT_FOUND,
370 "COMMAND_EXPIRED" => StatusCode::GONE,
371 "CONFLICT" => StatusCode::CONFLICT,
372 "OPERATION_NOT_SUPPORTED" => StatusCode::NOT_IMPLEMENTED,
373 "STORAGE_OPERATION_FAILED"
374 | "KV_OPERATION_FAILED"
375 | "TRANSPORT_DISPATCH_FAILED"
376 | "TRANSPORT_DISPATCH_REJECTED"
377 | "AGENT_ERROR"
378 | "COMMANDS_ERROR"
379 | "SERIALIZATION_FAILED"
380 | "HTTP_OPERATION_FAILED" => StatusCode::INTERNAL_SERVER_ERROR,
381 _ => StatusCode::INTERNAL_SERVER_ERROR,
382 };
383
384 let body = match serde_json::to_string(&self) {
385 Ok(json) => json,
386 Err(e) => {
387 error!("Failed to serialize error response: {}", e);
388 r#"{"code":"COMMANDS_ERROR","message":"Serialization error"}"#.to_string()
389 }
390 };
391
392 (status, body).into_response()
393 }
394}
395
396#[cfg(feature = "openapi")]
397mod openapi {
398 use super::*;
399 use utoipa::OpenApi;
400
401 #[derive(OpenApi)]
402 #[openapi(
403 paths(
404 create_command,
405 upload_complete,
406 get_command_status,
407 get_command_payload,
408 store_command_payload,
409 submit_response,
410 acquire_leases,
411 release_lease,
412 ),
413 components(
414 schemas(
415 CreateCommandRequest,
416 CreateCommandResponse,
417 UploadCompleteRequest,
418 UploadCompleteResponse,
419 CommandStatusResponse,
420 CommandPayloadResponse,
421 StorePayloadRequest,
422 SubmitResponseRequest,
423 CommandResponse,
424 LeaseRequest,
425 LeaseResponse,
426 ReleaseRequest,
427 ErrorResponse,
428 BodySpec,
430 CommandState,
431 StorageUpload,
432 ResponseHandling,
433 Envelope,
434 LeaseInfo,
435 )
436 ),
437 tags(
438 (name = "commands", description = "Command management"),
439 (name = "leases", description = "Lease management for polling deployments")
440 ),
441 info(
442 title = "Commands API",
443 description = "Alien Commands API",
444 version = "1.0.0"
445 ),
446 )]
447 pub struct ApiDoc;
448}
449
450#[cfg(feature = "openapi")]
451pub use openapi::ApiDoc;