Skip to main content

things3_cloud/commands/
webserver.rs

1use std::process::Command as ProcessCommand;
2
3use anyhow::{Context, Result};
4use clap::Args;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
8
9use crate::{app::Cli, commands::Command};
10
11#[derive(Debug, Args)]
12pub struct WebserverArgs {
13    /// Host interface to bind to
14    #[arg(long, default_value = "127.0.0.1")]
15    pub host: String,
16    /// TCP port to listen on
17    #[arg(long, default_value_t = 8765)]
18    pub port: u16,
19}
20
21#[derive(Debug, Deserialize)]
22struct CommandRequest {
23    args: Option<Vec<String>>,
24}
25
26#[derive(Debug, Serialize)]
27#[serde(tag = "status", rename_all = "snake_case")]
28enum CommandResponse {
29    Success {
30        data: Value,
31    },
32    Error {
33        error: String,
34        #[serde(flatten)]
35        details: ErrorDetails,
36    },
37}
38
39#[derive(Debug, Serialize, Default)]
40struct ErrorDetails {
41    #[serde(skip_serializing_if = "Option::is_none")]
42    returncode: Option<i32>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    stderr: Option<String>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    stdout: Option<String>,
47}
48
49#[derive(Debug)]
50struct RequestError {
51    status: StatusCode,
52    message: String,
53}
54
55impl RequestError {
56    fn new(status: StatusCode, message: impl Into<String>) -> Self {
57        Self {
58            status,
59            message: message.into(),
60        }
61    }
62}
63
64impl Command for WebserverArgs {
65    fn run_with_ctx(
66        &self,
67        _cli: &Cli,
68        _out: &mut dyn std::io::Write,
69        _ctx: &mut dyn crate::cmd_ctx::CmdCtx,
70    ) -> Result<()> {
71        let addr = format!("{}:{}", self.host, self.port);
72        let server =
73            Server::http(&addr).map_err(|err| anyhow::anyhow!("failed to bind {addr}: {err}"))?;
74        eprintln!("things3 webserver listening on http://{addr}");
75
76        for request in server.incoming_requests() {
77            if let Err(err) = handle_request(request) {
78                eprintln!("webserver request error: {err}");
79            }
80        }
81
82        Ok(())
83    }
84}
85
86fn handle_request(mut request: Request) -> Result<()> {
87    let (status, payload) = match process_request(&mut request) {
88        Ok(response) => (StatusCode(200), response),
89        Err(err) => (
90            err.status,
91            CommandResponse::Error {
92                error: err.message,
93                details: Default::default(),
94            },
95        ),
96    };
97
98    send_json(request, status, &payload)
99}
100
101fn process_request(request: &mut Request) -> std::result::Result<CommandResponse, RequestError> {
102    if request.method() != &Method::Post {
103        return Err(RequestError::new(StatusCode(405), "method not allowed"));
104    }
105
106    if request.url() != "/" {
107        return Err(RequestError::new(StatusCode(404), "not found"));
108    }
109
110    let mut body = String::new();
111    request
112        .as_reader()
113        .read_to_string(&mut body)
114        .map_err(|_| RequestError::new(StatusCode(400), "failed reading request body"))?;
115
116    let parsed = if body.trim().is_empty() {
117        CommandRequest { args: None }
118    } else {
119        serde_json::from_str::<CommandRequest>(&body).map_err(|err| {
120            RequestError::new(StatusCode(400), format!("invalid JSON payload: {err}"))
121        })?
122    };
123
124    let args = normalize_args(parsed.args);
125
126    execute_command(&args).map_err(|err| {
127        RequestError::new(StatusCode(500), format!("failed to execute command: {err}"))
128    })
129}
130
131fn normalize_args(args: Option<Vec<String>>) -> Vec<String> {
132    let mut out = args
133        .unwrap_or_default()
134        .into_iter()
135        .map(|s| s.trim().to_string())
136        .filter(|s| !s.is_empty())
137        .collect::<Vec<_>>();
138
139    if out.is_empty() {
140        out.push("today".to_string());
141    }
142
143    out
144}
145
146fn execute_command(args: &[String]) -> Result<CommandResponse> {
147    let exe = std::env::current_exe().context("failed to locate current executable")?;
148
149    let mut argv = args.to_vec();
150    if !argv.iter().any(|v| v == "--json") {
151        argv.insert(0, "--json".to_string());
152    }
153
154    let output = ProcessCommand::new(exe)
155        .args(&argv)
156        .output()
157        .context("failed to execute command")?;
158
159    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
160    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
161
162    if !output.status.success() {
163        return Ok(command_error_response(
164            "command failed",
165            output.status.code(),
166            stdout,
167            stderr,
168        ));
169    }
170
171    match serde_json::from_str::<Value>(&stdout) {
172        Ok(data) => Ok(CommandResponse::Success { data }),
173        Err(_) => Ok(command_error_response(
174            "command did not return JSON output",
175            output.status.code(),
176            stdout,
177            stderr,
178        )),
179    }
180}
181
182fn command_error_response(
183    message: &str,
184    returncode: Option<i32>,
185    stdout: String,
186    stderr: String,
187) -> CommandResponse {
188    CommandResponse::Error {
189        error: message.to_string(),
190        details: ErrorDetails {
191            returncode,
192            stderr: (!stderr.is_empty()).then_some(stderr),
193            stdout: (!stdout.is_empty()).then_some(stdout),
194        },
195    }
196}
197
198fn send_json<T: Serialize>(request: Request, status: StatusCode, payload: &T) -> Result<()> {
199    let body = serde_json::to_string(payload)?;
200    let content_type = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
201        .expect("static content-type header should always be valid");
202
203    let response = Response::from_string(body)
204        .with_status_code(status)
205        .with_header(content_type);
206    request.respond(response)?;
207    Ok(())
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn normalize_args_defaults_to_today() {
216        assert_eq!(normalize_args(None), vec!["today"]);
217        assert_eq!(
218            normalize_args(Some(vec!["".to_string(), " ".to_string()])),
219            vec!["today"]
220        );
221    }
222
223    #[test]
224    fn normalize_args_trims_entries() {
225        assert_eq!(
226            normalize_args(Some(vec![" today ".to_string(), "  ".to_string()])),
227            vec!["today"]
228        );
229    }
230}