Skip to main content

shell_tunnel/api/
handlers.rs

1//! REST API handlers.
2
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use axum::{
7    extract::{Path, State},
8    http::StatusCode,
9    Json,
10};
11
12use super::types::{
13    CreateSessionRequest, CreateSessionResponse, ErrorResponse, ExecuteCommandRequest,
14    ExecuteCommandResponse, ListSessionsResponse, SessionStatusResponse, SessionSummary,
15};
16use crate::execution::{Command, CommandExecutor};
17use crate::session::{SessionConfig, SessionId, SessionState, SessionStore};
18
19/// Shared application state.
20#[derive(Clone)]
21pub struct AppState {
22    pub store: Arc<SessionStore>,
23    pub executor: Arc<CommandExecutor>,
24    /// Where execution events are recorded; disabled unless configured.
25    pub audit: Arc<crate::audit::AuditSink>,
26}
27
28impl AppState {
29    pub fn new() -> Self {
30        let store = Arc::new(SessionStore::new());
31        let executor = Arc::new(CommandExecutor::new(Arc::clone(&store)));
32        Self {
33            store,
34            executor,
35            audit: Arc::new(crate::audit::AuditSink::Disabled),
36        }
37    }
38
39    /// Record execution events to `sink`.
40    pub fn with_audit(mut self, sink: Arc<crate::audit::AuditSink>) -> Self {
41        self.audit = sink;
42        self
43    }
44}
45
46/// Build the execution event for a finished command.
47///
48/// Written from the handler rather than from middleware because only here are
49/// the command and its outcome both in hand — an entry saying a request reached
50/// `/execute` would say almost nothing about what ran.
51fn execution_event(
52    identity: Option<crate::audit::Identity>,
53    route: &str,
54    command: &str,
55    session_id: Option<u64>,
56    result: &crate::execution::ExecutionResult,
57) -> crate::audit::AuditEvent {
58    let mut event = crate::audit::AuditEvent::new("execute")
59        .with_identity(identity)
60        .with_route(route)
61        .with_command(command)
62        .with_outcome(
63            result.exit_code,
64            result.timed_out,
65            result.duration.as_millis() as u64,
66        );
67    if let Some(id) = session_id {
68        event = event.with_session(id);
69    }
70    event
71}
72
73impl Default for AppState {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79/// Health check endpoint.
80pub async fn health() -> &'static str {
81    "OK"
82}
83
84/// API information endpoint.
85pub async fn api_info() -> Json<serde_json::Value> {
86    Json(serde_json::json!({
87        "name": "shell-tunnel",
88        "version": env!("CARGO_PKG_VERSION"),
89        "status": "running"
90    }))
91}
92
93/// List all sessions.
94pub async fn list_sessions(
95    State(state): State<AppState>,
96) -> Result<Json<ListSessionsResponse>, (StatusCode, Json<ErrorResponse>)> {
97    let ids = state.store.list_ids().map_err(|e| {
98        (
99            StatusCode::INTERNAL_SERVER_ERROR,
100            Json(ErrorResponse::internal_error(e.to_string())),
101        )
102    })?;
103
104    let mut sessions = Vec::with_capacity(ids.len());
105    for id in ids {
106        if let Ok(Some(session)) = state.store.get(&id) {
107            sessions.push(SessionSummary {
108                session_id: session.id.as_u64(),
109                state: format!("{:?}", session.state),
110                idle_seconds: session.idle_duration().as_secs_f64(),
111            });
112        }
113    }
114
115    Ok(Json(ListSessionsResponse {
116        count: sessions.len(),
117        sessions,
118    }))
119}
120
121/// Create a new session.
122pub async fn create_session(
123    State(state): State<AppState>,
124    Json(req): Json<CreateSessionRequest>,
125) -> Result<(StatusCode, Json<CreateSessionResponse>), (StatusCode, Json<ErrorResponse>)> {
126    let config = SessionConfig {
127        shell: req.shell,
128        working_dir: req.working_dir,
129        env: req.env,
130    };
131
132    let session_id = state.store.create(config).map_err(|e| {
133        (
134            StatusCode::INTERNAL_SERVER_ERROR,
135            Json(ErrorResponse::internal_error(e.to_string())),
136        )
137    })?;
138
139    // Transition to Idle state (ready for commands)
140    state
141        .store
142        .update(&session_id, |s| {
143            let _ = s.state.transition_to(SessionState::Idle);
144        })
145        .ok();
146
147    Ok((
148        StatusCode::CREATED,
149        Json(CreateSessionResponse::new(session_id)),
150    ))
151}
152
153/// Get session status.
154pub async fn get_session(
155    State(state): State<AppState>,
156    Path(session_id): Path<u64>,
157) -> Result<Json<SessionStatusResponse>, (StatusCode, Json<ErrorResponse>)> {
158    let id = SessionId::from_raw(session_id);
159
160    let session = state
161        .store
162        .get(&id)
163        .map_err(|e| {
164            (
165                StatusCode::INTERNAL_SERVER_ERROR,
166                Json(ErrorResponse::internal_error(e.to_string())),
167            )
168        })?
169        .ok_or_else(|| {
170            (
171                StatusCode::NOT_FOUND,
172                Json(ErrorResponse::session_not_found(&session_id.to_string())),
173            )
174        })?;
175
176    Ok(Json(SessionStatusResponse::from_session(&session)))
177}
178
179/// Delete a session.
180pub async fn delete_session(
181    State(state): State<AppState>,
182    Path(session_id): Path<u64>,
183) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
184    let id = SessionId::from_raw(session_id);
185
186    // First mark as terminated
187    state
188        .store
189        .update(&id, |s| {
190            let _ = s.state.transition_to(SessionState::Terminated);
191        })
192        .map_err(|_| {
193            (
194                StatusCode::NOT_FOUND,
195                Json(ErrorResponse::session_not_found(&session_id.to_string())),
196            )
197        })?;
198
199    // Then remove from store
200    state.store.remove(&id).map_err(|e| {
201        (
202            StatusCode::INTERNAL_SERVER_ERROR,
203            Json(ErrorResponse::internal_error(e.to_string())),
204        )
205    })?;
206
207    Ok(StatusCode::NO_CONTENT)
208}
209
210/// Execute a command in a session.
211pub async fn execute_command(
212    State(state): State<AppState>,
213    Path(session_id): Path<u64>,
214    identity: Option<axum::Extension<crate::audit::Identity>>,
215    Json(req): Json<ExecuteCommandRequest>,
216) -> Result<Json<ExecuteCommandResponse>, (StatusCode, Json<ErrorResponse>)> {
217    let id = SessionId::from_raw(session_id);
218
219    // Verify session exists and is in valid state
220    let session = state
221        .store
222        .get(&id)
223        .map_err(|e| {
224            (
225                StatusCode::INTERNAL_SERVER_ERROR,
226                Json(ErrorResponse::internal_error(e.to_string())),
227            )
228        })?
229        .ok_or_else(|| {
230            (
231                StatusCode::NOT_FOUND,
232                Json(ErrorResponse::session_not_found(&session_id.to_string())),
233            )
234        })?;
235
236    if !session.state.can_execute() {
237        return Err((
238            StatusCode::CONFLICT,
239            Json(ErrorResponse::invalid_state(session.state)),
240        ));
241    }
242
243    // Build command
244    let mut cmd = Command::new(&req.command);
245    if let Some(dir) = &req.working_dir {
246        cmd = cmd.working_dir(PathBuf::from(dir));
247    }
248    if let Some(timeout) = req.timeout() {
249        cmd = cmd.timeout(timeout);
250    }
251    for (key, value) in &req.env {
252        cmd = cmd.env(key, value);
253    }
254
255    // Execute
256    let result = state
257        .executor
258        .execute_in_session(&id, &cmd)
259        .await
260        .map_err(|e| {
261            (
262                StatusCode::INTERNAL_SERVER_ERROR,
263                Json(ErrorResponse::internal_error(e.to_string())),
264            )
265        })?;
266
267    state.audit.record(execution_event(
268        identity.map(|axum::Extension(id)| id),
269        "POST /api/v1/sessions/{id}/execute",
270        &req.command,
271        Some(session_id),
272        &result,
273    ));
274
275    // Update session context
276    state
277        .store
278        .update(&id, |s| {
279            s.context.record_execution(&req.command, result.exit_code);
280        })
281        .ok();
282
283    Ok(Json(ExecuteCommandResponse::from_result(&result)))
284}
285
286/// Execute a command without session (one-shot).
287pub async fn execute_oneshot(
288    State(state): State<AppState>,
289    identity: Option<axum::Extension<crate::audit::Identity>>,
290    Json(req): Json<ExecuteCommandRequest>,
291) -> Result<Json<ExecuteCommandResponse>, (StatusCode, Json<ErrorResponse>)> {
292    // Build command
293    let mut cmd = Command::new(&req.command);
294    if let Some(dir) = &req.working_dir {
295        cmd = cmd.working_dir(PathBuf::from(dir));
296    }
297    if let Some(timeout) = req.timeout() {
298        cmd = cmd.timeout(timeout);
299    }
300    for (key, value) in &req.env {
301        cmd = cmd.env(key, value);
302    }
303
304    // Execute directly without session (off the async runtime workers)
305    let result = state.executor.execute(&cmd).await.map_err(|e| {
306        (
307            StatusCode::INTERNAL_SERVER_ERROR,
308            Json(ErrorResponse::internal_error(e.to_string())),
309        )
310    })?;
311
312    state.audit.record(execution_event(
313        identity.map(|axum::Extension(id)| id),
314        "POST /api/v1/execute",
315        &req.command,
316        None,
317        &result,
318    ));
319
320    Ok(Json(ExecuteCommandResponse::from_result(&result)))
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn test_app_state_new() {
329        let state = AppState::new();
330        assert_eq!(state.store.count(), 0);
331    }
332
333    #[tokio::test]
334    async fn test_health_endpoint() {
335        let response = health().await;
336        assert_eq!(response, "OK");
337    }
338
339    #[tokio::test]
340    async fn test_api_info_endpoint() {
341        let response = api_info().await;
342        let json = response.0;
343        assert_eq!(json["name"], "shell-tunnel");
344        assert_eq!(json["status"], "running");
345    }
346}